mirror of
https://github.com/johndoe6345789/AutoMetabuilder.git
synced 2026-04-24 13:54:59 +00:00
Implement Workflow Builder and Refactor UI Tests: Add visual workflow builder for dynamic task management, enhance Playwright tests to improve UI automation coverage, and introduce a new base HTML template.
This commit is contained in:
@@ -29,4 +29,6 @@ model: openai/gpt-4o
|
||||
# Test Comment
|
||||
# Test Comment
|
||||
# Test Comment
|
||||
# Test Comment
|
||||
# Test Comment
|
||||
# Test Comment
|
||||
@@ -4,6 +4,7 @@ import secrets
|
||||
from fastapi import FastAPI, Request, Form, BackgroundTasks, Depends, HTTPException, status
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from dotenv import load_dotenv, set_key
|
||||
import subprocess
|
||||
@@ -39,6 +40,11 @@ mock_running = False
|
||||
templates_dir = os.path.join(os.path.dirname(__file__), "templates")
|
||||
templates = Jinja2Templates(directory=templates_dir)
|
||||
|
||||
# Setup static files
|
||||
static_dir = os.path.join(os.path.dirname(__file__), "static")
|
||||
if os.path.exists(static_dir):
|
||||
app.mount("/static", StaticFiles(directory=static_dir), name="static")
|
||||
|
||||
def run_bot_task():
|
||||
global bot_process, mock_running
|
||||
if os.environ.get("MOCK_WEB_UI") == "true":
|
||||
|
||||
640
src/autometabuilder/web/static/css/main.css
Normal file
640
src/autometabuilder/web/static/css/main.css
Normal file
@@ -0,0 +1,640 @@
|
||||
/* ==========================================================================
|
||||
AutoMetabuilder - Main Stylesheet
|
||||
========================================================================== */
|
||||
|
||||
/* ==========================================================================
|
||||
1. CSS Custom Properties (Theme Variables)
|
||||
========================================================================== */
|
||||
:root {
|
||||
/* Light mode (default) */
|
||||
--amb-bg-primary: #f8f9fa;
|
||||
--amb-bg-secondary: #ffffff;
|
||||
--amb-bg-tertiary: #e9ecef;
|
||||
--amb-text-primary: #212529;
|
||||
--amb-text-secondary: #495057;
|
||||
--amb-text-muted: #6c757d;
|
||||
--amb-border-color: #dee2e6;
|
||||
--amb-card-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--amb-sidebar-bg: #212529;
|
||||
--amb-sidebar-text: #adb5bd;
|
||||
--amb-sidebar-text-active: #ffffff;
|
||||
--amb-sidebar-hover: rgba(255, 255, 255, 0.1);
|
||||
--amb-code-bg: #f1f3f5;
|
||||
--amb-accent: #0d6efd;
|
||||
--amb-accent-hover: #0b5ed7;
|
||||
--amb-success: #198754;
|
||||
--amb-warning: #ffc107;
|
||||
--amb-danger: #dc3545;
|
||||
--amb-sidebar-width: 260px;
|
||||
--amb-transition: 0.2s ease;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] {
|
||||
--amb-bg-primary: #1a1d21;
|
||||
--amb-bg-secondary: #212529;
|
||||
--amb-bg-tertiary: #2c3034;
|
||||
--amb-text-primary: #f8f9fa;
|
||||
--amb-text-secondary: #ced4da;
|
||||
--amb-text-muted: #adb5bd;
|
||||
--amb-border-color: #495057;
|
||||
--amb-card-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.3);
|
||||
--amb-sidebar-bg: #0d1117;
|
||||
--amb-sidebar-text: #8b949e;
|
||||
--amb-sidebar-text-active: #f0f6fc;
|
||||
--amb-sidebar-hover: rgba(255, 255, 255, 0.05);
|
||||
--amb-code-bg: #2d333b;
|
||||
--amb-accent: #58a6ff;
|
||||
--amb-accent-hover: #79b8ff;
|
||||
--amb-success: #3fb950;
|
||||
--amb-warning: #d29922;
|
||||
--amb-danger: #f85149;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
2. Base Styles
|
||||
========================================================================== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background-color: var(--amb-bg-primary);
|
||||
color: var(--amb-text-primary);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
transition: background-color var(--amb-transition), color var(--amb-transition);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
3. Layout
|
||||
========================================================================== */
|
||||
.amb-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.amb-sidebar {
|
||||
width: var(--amb-sidebar-width);
|
||||
background: var(--amb-sidebar-bg);
|
||||
color: var(--amb-sidebar-text);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 1000;
|
||||
transition: background-color var(--amb-transition);
|
||||
}
|
||||
|
||||
.amb-sidebar-header {
|
||||
padding: 1.5rem 1.25rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.amb-sidebar-header h5 {
|
||||
margin: 0;
|
||||
color: var(--amb-sidebar-text-active);
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.amb-sidebar-header .bi {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.amb-nav {
|
||||
flex: 1;
|
||||
padding: 1rem 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.amb-nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
color: var(--amb-sidebar-text);
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
transition: all var(--amb-transition);
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
.amb-nav-link:hover {
|
||||
background: var(--amb-sidebar-hover);
|
||||
color: var(--amb-sidebar-text-active);
|
||||
}
|
||||
|
||||
.amb-nav-link.active {
|
||||
background: var(--amb-sidebar-hover);
|
||||
color: var(--amb-sidebar-text-active);
|
||||
border-left-color: var(--amb-accent);
|
||||
}
|
||||
|
||||
.amb-nav-link .bi {
|
||||
font-size: 1.1rem;
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Sidebar Footer */
|
||||
.amb-sidebar-footer {
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.amb-sidebar-footer .amb-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--amb-sidebar-text);
|
||||
}
|
||||
|
||||
.amb-theme-toggle {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--amb-sidebar-text);
|
||||
cursor: pointer;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
transition: all var(--amb-transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.amb-theme-toggle:hover {
|
||||
background: var(--amb-sidebar-hover);
|
||||
color: var(--amb-sidebar-text-active);
|
||||
}
|
||||
|
||||
.amb-theme-toggle .bi {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.amb-main {
|
||||
flex: 1;
|
||||
margin-left: var(--amb-sidebar-width);
|
||||
padding: 2rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
.amb-section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.amb-section.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.amb-section-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.amb-section-header h1 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.amb-section-header p {
|
||||
margin: 0;
|
||||
color: var(--amb-text-muted);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
4. Cards
|
||||
========================================================================== */
|
||||
.amb-card {
|
||||
background: var(--amb-bg-secondary);
|
||||
border: 1px solid var(--amb-border-color);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: var(--amb-card-shadow);
|
||||
margin-bottom: 1.5rem;
|
||||
transition: background-color var(--amb-transition), border-color var(--amb-transition), box-shadow var(--amb-transition);
|
||||
}
|
||||
|
||||
.amb-card-header {
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--amb-border-color);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.amb-card-header h5 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.amb-card-body {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.amb-card-footer {
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--amb-border-color);
|
||||
background: var(--amb-bg-tertiary);
|
||||
border-radius: 0 0 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
5. Forms
|
||||
========================================================================== */
|
||||
.amb-form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.amb-form-label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.amb-required {
|
||||
color: var(--amb-danger);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
/* Form control overrides for dark mode */
|
||||
[data-theme="dark"] .form-control,
|
||||
[data-theme="dark"] .form-select {
|
||||
background-color: var(--amb-bg-tertiary);
|
||||
border-color: var(--amb-border-color);
|
||||
color: var(--amb-text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-control:focus,
|
||||
[data-theme="dark"] .form-select:focus {
|
||||
background-color: var(--amb-bg-tertiary);
|
||||
border-color: var(--amb-accent);
|
||||
color: var(--amb-text-primary);
|
||||
box-shadow: 0 0 0 0.25rem rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-control::placeholder {
|
||||
color: var(--amb-text-muted);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-control:disabled,
|
||||
[data-theme="dark"] .form-select:disabled {
|
||||
background-color: var(--amb-bg-primary);
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
/* Validation states */
|
||||
.form-control.is-valid,
|
||||
.form-select.is-valid {
|
||||
border-color: var(--amb-success);
|
||||
}
|
||||
|
||||
.form-control.is-invalid,
|
||||
.form-select.is-invalid {
|
||||
border-color: var(--amb-danger);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
6. Choices.js Overrides
|
||||
========================================================================== */
|
||||
.choices {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.choices__inner {
|
||||
background-color: var(--amb-bg-secondary);
|
||||
border-color: var(--amb-border-color);
|
||||
border-radius: 0.375rem;
|
||||
min-height: 38px;
|
||||
padding: 0.375rem 0.75rem;
|
||||
transition: border-color var(--amb-transition), box-shadow var(--amb-transition);
|
||||
}
|
||||
|
||||
.choices__inner:focus-within {
|
||||
border-color: var(--amb-accent);
|
||||
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
|
||||
}
|
||||
|
||||
.choices__list--dropdown {
|
||||
background-color: var(--amb-bg-secondary);
|
||||
border-color: var(--amb-border-color);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: var(--amb-card-shadow);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.choices__list--dropdown .choices__item--selectable.is-highlighted {
|
||||
background-color: var(--amb-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.choices__input {
|
||||
background-color: transparent !important;
|
||||
color: var(--amb-text-primary);
|
||||
}
|
||||
|
||||
.choices__placeholder {
|
||||
color: var(--amb-text-muted);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.choices[data-type*="select-one"] .choices__input {
|
||||
background-color: var(--amb-bg-secondary) !important;
|
||||
}
|
||||
|
||||
/* Dark mode Choices.js */
|
||||
[data-theme="dark"] .choices__inner {
|
||||
background-color: var(--amb-bg-tertiary);
|
||||
border-color: var(--amb-border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .choices__inner:focus-within {
|
||||
border-color: var(--amb-accent);
|
||||
box-shadow: 0 0 0 0.25rem rgba(88, 166, 255, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .choices__list--dropdown {
|
||||
background-color: var(--amb-bg-secondary);
|
||||
border-color: var(--amb-border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .choices__list--dropdown .choices__item {
|
||||
color: var(--amb-text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .choices[data-type*="select-one"] .choices__input {
|
||||
background-color: var(--amb-bg-tertiary) !important;
|
||||
color: var(--amb-text-primary);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
7. Tables
|
||||
========================================================================== */
|
||||
.amb-table {
|
||||
background: var(--amb-bg-secondary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .table {
|
||||
--bs-table-bg: var(--amb-bg-secondary);
|
||||
--bs-table-color: var(--amb-text-primary);
|
||||
--bs-table-border-color: var(--amb-border-color);
|
||||
--bs-table-striped-bg: var(--amb-bg-tertiary);
|
||||
--bs-table-hover-bg: var(--amb-bg-tertiary);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
8. Logs Panel
|
||||
========================================================================== */
|
||||
.amb-logs {
|
||||
background: var(--amb-code-bg);
|
||||
border: 1px solid var(--amb-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.5;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
transition: background-color var(--amb-transition), border-color var(--amb-transition);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
9. Status Indicators
|
||||
========================================================================== */
|
||||
.amb-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.amb-status-idle {
|
||||
background: rgba(25, 135, 84, 0.1);
|
||||
color: var(--amb-success);
|
||||
}
|
||||
|
||||
.amb-status-running {
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
color: var(--amb-warning);
|
||||
}
|
||||
|
||||
.amb-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.amb-status-running .amb-status-dot {
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
10. Workflow Builder
|
||||
========================================================================== */
|
||||
.amb-workflow-task {
|
||||
background: var(--amb-bg-secondary);
|
||||
border: 1px solid var(--amb-border-color);
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: var(--amb-card-shadow);
|
||||
transition: background-color var(--amb-transition), border-color var(--amb-transition);
|
||||
}
|
||||
|
||||
.amb-workflow-task-header {
|
||||
background: var(--amb-accent);
|
||||
color: #fff;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.amb-workflow-task-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.amb-workflow-step {
|
||||
background: var(--amb-bg-tertiary);
|
||||
border: 1px solid var(--amb-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
transition: background-color var(--amb-transition), border-color var(--amb-transition);
|
||||
}
|
||||
|
||||
.amb-workflow-step:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.amb-workflow-step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
11. Buttons
|
||||
========================================================================== */
|
||||
[data-theme="dark"] .btn-light {
|
||||
background-color: var(--amb-bg-tertiary);
|
||||
border-color: var(--amb-border-color);
|
||||
color: var(--amb-text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .btn-light:hover {
|
||||
background-color: var(--amb-bg-secondary);
|
||||
border-color: var(--amb-border-color);
|
||||
color: var(--amb-text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .btn-outline-primary {
|
||||
color: var(--amb-accent);
|
||||
border-color: var(--amb-accent);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .btn-outline-primary:hover {
|
||||
background-color: var(--amb-accent);
|
||||
border-color: var(--amb-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .btn-outline-secondary {
|
||||
color: var(--amb-text-secondary);
|
||||
border-color: var(--amb-border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .btn-outline-secondary:hover {
|
||||
background-color: var(--amb-bg-tertiary);
|
||||
border-color: var(--amb-border-color);
|
||||
color: var(--amb-text-primary);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
12. Badges
|
||||
========================================================================== */
|
||||
[data-theme="dark"] .badge.bg-light {
|
||||
background-color: var(--amb-bg-tertiary) !important;
|
||||
color: var(--amb-text-primary) !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
13. List Groups
|
||||
========================================================================== */
|
||||
[data-theme="dark"] .list-group-item {
|
||||
background-color: var(--amb-bg-secondary);
|
||||
border-color: var(--amb-border-color);
|
||||
color: var(--amb-text-primary);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
14. Input Groups
|
||||
========================================================================== */
|
||||
[data-theme="dark"] .input-group-text {
|
||||
background-color: var(--amb-bg-tertiary);
|
||||
border-color: var(--amb-border-color);
|
||||
color: var(--amb-text-primary);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
15. Textarea (Code Editor)
|
||||
========================================================================== */
|
||||
.amb-code-editor {
|
||||
font-family: "SF Mono", "Monaco", "Inconsolata", "Fira Code", monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
background: var(--amb-code-bg);
|
||||
border: 1px solid var(--amb-border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
resize: vertical;
|
||||
transition: background-color var(--amb-transition), border-color var(--amb-transition);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .amb-code-editor {
|
||||
background: var(--amb-code-bg);
|
||||
color: var(--amb-text-primary);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
16. Utilities
|
||||
========================================================================== */
|
||||
.amb-scrollable {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.amb-gap-2 {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.amb-gap-3 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
17. Responsive
|
||||
========================================================================== */
|
||||
@media (max-width: 991.98px) {
|
||||
.amb-sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform var(--amb-transition);
|
||||
}
|
||||
|
||||
.amb-sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.amb-main {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
18. Transitions
|
||||
========================================================================== */
|
||||
.amb-fade-in {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
308
src/autometabuilder/web/static/js/main.js
Normal file
308
src/autometabuilder/web/static/js/main.js
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* AutoMetabuilder - Main JavaScript
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Theme Manager
|
||||
========================================================================== */
|
||||
const ThemeManager = {
|
||||
STORAGE_KEY: 'amb-theme',
|
||||
|
||||
init() {
|
||||
const saved = localStorage.getItem(this.STORAGE_KEY);
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const theme = saved || (prefersDark ? 'dark' : 'light');
|
||||
this.setTheme(theme);
|
||||
|
||||
document.querySelectorAll('[data-theme-toggle]').forEach(btn => {
|
||||
btn.addEventListener('click', () => this.toggle());
|
||||
});
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)')
|
||||
.addEventListener('change', e => {
|
||||
if (!localStorage.getItem(this.STORAGE_KEY)) {
|
||||
this.setTheme(e.matches ? 'dark' : 'light');
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem(this.STORAGE_KEY, theme);
|
||||
this.updateToggleIcon(theme);
|
||||
},
|
||||
|
||||
toggle() {
|
||||
const current = document.documentElement.getAttribute('data-theme');
|
||||
this.setTheme(current === 'dark' ? 'light' : 'dark');
|
||||
},
|
||||
|
||||
updateToggleIcon(theme) {
|
||||
document.querySelectorAll('[data-theme-toggle] i').forEach(icon => {
|
||||
icon.className = theme === 'dark' ? 'bi bi-moon-fill' : 'bi bi-sun-fill';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
Navigation Manager
|
||||
========================================================================== */
|
||||
const NavigationManager = {
|
||||
init() {
|
||||
document.querySelectorAll('[data-section]').forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const target = link.dataset.section;
|
||||
this.showSection(target);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle initial hash
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash && document.querySelector(`#${hash}`)) {
|
||||
this.showSection(hash);
|
||||
}
|
||||
|
||||
// Handle browser back/forward
|
||||
window.addEventListener('popstate', () => {
|
||||
const hash = window.location.hash.slice(1);
|
||||
if (hash && document.querySelector(`#${hash}`)) {
|
||||
this.showSection(hash, false);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
showSection(sectionId, updateHistory = true) {
|
||||
// Hide all sections
|
||||
document.querySelectorAll('.amb-section').forEach(s => s.classList.remove('active'));
|
||||
|
||||
// Show target section
|
||||
const targetSection = document.querySelector(`#${sectionId}`);
|
||||
if (targetSection) {
|
||||
targetSection.classList.add('active');
|
||||
}
|
||||
|
||||
// Update nav active state
|
||||
document.querySelectorAll('.amb-nav-link').forEach(n => n.classList.remove('active'));
|
||||
const activeLink = document.querySelector(`[data-section="${sectionId}"]`);
|
||||
if (activeLink) {
|
||||
activeLink.classList.add('active');
|
||||
}
|
||||
|
||||
// Update URL hash
|
||||
if (updateHistory) {
|
||||
history.pushState(null, '', `#${sectionId}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
Choices Manager
|
||||
========================================================================== */
|
||||
const ChoicesManager = {
|
||||
instances: [],
|
||||
|
||||
init() {
|
||||
this.initAll();
|
||||
},
|
||||
|
||||
initAll() {
|
||||
document.querySelectorAll('[data-choices]').forEach(el => {
|
||||
// Skip if already initialized
|
||||
if (el.classList.contains('choices__input')) return;
|
||||
|
||||
const optionsStr = el.dataset.choicesOptions || '{}';
|
||||
let options = {};
|
||||
try {
|
||||
options = JSON.parse(optionsStr);
|
||||
} catch (e) {
|
||||
console.warn('Invalid choices options:', optionsStr);
|
||||
}
|
||||
|
||||
const instance = new Choices(el, {
|
||||
searchEnabled: true,
|
||||
shouldSort: false,
|
||||
allowHTML: false,
|
||||
removeItemButton: options.removeItemButton || false,
|
||||
placeholder: true,
|
||||
placeholderValue: el.dataset.placeholder || 'Select...',
|
||||
searchPlaceholderValue: 'Type to search...',
|
||||
noResultsText: 'No results found',
|
||||
noChoicesText: 'No choices available',
|
||||
...options
|
||||
});
|
||||
this.instances.push(instance);
|
||||
});
|
||||
},
|
||||
|
||||
destroy() {
|
||||
this.instances.forEach(instance => {
|
||||
try {
|
||||
instance.destroy();
|
||||
} catch (e) {
|
||||
// Ignore errors from already destroyed instances
|
||||
}
|
||||
});
|
||||
this.instances = [];
|
||||
},
|
||||
|
||||
refresh() {
|
||||
this.destroy();
|
||||
this.initAll();
|
||||
}
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
Form Validator
|
||||
========================================================================== */
|
||||
const FormValidator = {
|
||||
init() {
|
||||
document.querySelectorAll('form[data-validate]').forEach(form => {
|
||||
form.addEventListener('submit', event => {
|
||||
if (!form.checkValidity()) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
form.classList.add('was-validated');
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
Status Poller
|
||||
========================================================================== */
|
||||
const StatusPoller = {
|
||||
logsInterval: null,
|
||||
statusInterval: null,
|
||||
|
||||
init() {
|
||||
this.refreshLogs();
|
||||
this.refreshStatus();
|
||||
this.logsInterval = setInterval(() => this.refreshLogs(), 2000);
|
||||
this.statusInterval = setInterval(() => this.refreshStatus(), 2000);
|
||||
},
|
||||
|
||||
async refreshLogs() {
|
||||
try {
|
||||
const response = await fetch('/api/logs');
|
||||
const data = await response.json();
|
||||
const logsPre = document.getElementById('logs');
|
||||
if (!logsPre) return;
|
||||
|
||||
const wasAtBottom = logsPre.scrollHeight - logsPre.clientHeight <= logsPre.scrollTop + 1;
|
||||
logsPre.textContent = data.logs;
|
||||
if (wasAtBottom) {
|
||||
logsPre.scrollTop = logsPre.scrollHeight;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async refreshStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
const data = await response.json();
|
||||
|
||||
// Update status indicator
|
||||
const statusIndicator = document.getElementById('status-indicator');
|
||||
if (statusIndicator) {
|
||||
if (data.is_running) {
|
||||
statusIndicator.className = 'amb-status amb-status-running';
|
||||
statusIndicator.innerHTML = '<span class="amb-status-dot"></span> Running';
|
||||
} else {
|
||||
statusIndicator.className = 'amb-status amb-status-idle';
|
||||
statusIndicator.innerHTML = '<span class="amb-status-dot"></span> Idle';
|
||||
}
|
||||
}
|
||||
|
||||
// Update MVP badge
|
||||
const mvpBadge = document.getElementById('mvp-badge');
|
||||
if (mvpBadge) {
|
||||
if (data.mvp_reached) {
|
||||
mvpBadge.className = 'badge bg-primary';
|
||||
mvpBadge.innerHTML = '<i class="bi bi-check-circle-fill"></i> Reached';
|
||||
} else {
|
||||
mvpBadge.className = 'badge bg-secondary';
|
||||
mvpBadge.innerHTML = '<i class="bi bi-hourglass-split"></i> In Progress';
|
||||
}
|
||||
}
|
||||
|
||||
// Update run button
|
||||
const runBtn = document.getElementById('run-btn');
|
||||
if (runBtn) {
|
||||
runBtn.disabled = data.is_running;
|
||||
}
|
||||
|
||||
// Update progress bar visibility
|
||||
const progressBar = document.getElementById('status-progress');
|
||||
if (progressBar) {
|
||||
progressBar.style.display = data.is_running ? 'block' : 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching status:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
Toast Notifications
|
||||
========================================================================== */
|
||||
const Toast = {
|
||||
show(message, type = 'info') {
|
||||
const container = document.getElementById('toast-container') || this.createContainer();
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast align-items-center text-bg-${type} border-0 show`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.innerHTML = `
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">${message}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(toast);
|
||||
|
||||
// Auto remove after 5 seconds
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 5000);
|
||||
|
||||
// Close button
|
||||
toast.querySelector('.btn-close').addEventListener('click', () => toast.remove());
|
||||
},
|
||||
|
||||
createContainer() {
|
||||
const container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'toast-container position-fixed bottom-0 end-0 p-3';
|
||||
container.style.zIndex = '1100';
|
||||
document.body.appendChild(container);
|
||||
return container;
|
||||
}
|
||||
};
|
||||
|
||||
/* ==========================================================================
|
||||
App Initialization
|
||||
========================================================================== */
|
||||
const App = {
|
||||
init() {
|
||||
ThemeManager.init();
|
||||
NavigationManager.init();
|
||||
ChoicesManager.init();
|
||||
FormValidator.init();
|
||||
StatusPoller.init();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', () => App.init());
|
||||
|
||||
// Export for use in other scripts
|
||||
window.App = App;
|
||||
window.ThemeManager = ThemeManager;
|
||||
window.NavigationManager = NavigationManager;
|
||||
window.ChoicesManager = ChoicesManager;
|
||||
window.FormValidator = FormValidator;
|
||||
window.StatusPoller = StatusPoller;
|
||||
window.Toast = Toast;
|
||||
279
src/autometabuilder/web/static/js/workflow.js
Normal file
279
src/autometabuilder/web/static/js/workflow.js
Normal file
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* AutoMetabuilder - Workflow Builder
|
||||
*/
|
||||
|
||||
const WorkflowBuilder = {
|
||||
workflow: [],
|
||||
stepDefinitions: {},
|
||||
allSuggestions: new Set(),
|
||||
container: null,
|
||||
textarea: null,
|
||||
|
||||
init(containerId, textareaId, stepDefinitions) {
|
||||
this.container = document.getElementById(containerId);
|
||||
this.textarea = document.getElementById(textareaId);
|
||||
this.stepDefinitions = stepDefinitions || {};
|
||||
|
||||
// Build all suggestions set
|
||||
Object.values(this.stepDefinitions).forEach(def => {
|
||||
Object.values(def.fields || {}).forEach(f => {
|
||||
if (f.suggestions) {
|
||||
f.suggestions.forEach(s => this.allSuggestions.add(s));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Parse initial workflow
|
||||
try {
|
||||
this.workflow = JSON.parse(this.textarea.value || '[]');
|
||||
} catch (e) {
|
||||
console.error('Failed to parse workflow JSON', e);
|
||||
this.workflow = [];
|
||||
}
|
||||
|
||||
this.render();
|
||||
},
|
||||
|
||||
render() {
|
||||
if (!this.container) return;
|
||||
|
||||
this.container.innerHTML = '';
|
||||
|
||||
this.workflow.forEach((task, taskIdx) => {
|
||||
const taskCard = document.createElement('div');
|
||||
taskCard.className = 'amb-workflow-task';
|
||||
taskCard.innerHTML = `
|
||||
<div class="amb-workflow-task-header">
|
||||
<div class="d-flex align-items-center gap-2 flex-grow-1">
|
||||
<input type="text" class="form-control form-control-sm" style="max-width: 200px;"
|
||||
value="${this.escapeHtml(task.name || '')}"
|
||||
data-choices data-placeholder="Task name"
|
||||
onchange="WorkflowBuilder.updateTask(${taskIdx}, 'name', this.value)"
|
||||
placeholder="Task Name">
|
||||
<select class="form-select form-select-sm" style="max-width: 160px;"
|
||||
onchange="WorkflowBuilder.updateTask(${taskIdx}, 'type', this.value)">
|
||||
<option value="" ${!task.type ? 'selected' : ''}>Standard</option>
|
||||
<option value="loop" ${task.type === 'loop' ? 'selected' : ''}>Loop</option>
|
||||
</select>
|
||||
${task.type === 'loop' ? `
|
||||
<input type="number" class="form-control form-control-sm" style="max-width: 80px;"
|
||||
value="${task.max_iterations || 1}"
|
||||
onchange="WorkflowBuilder.updateTask(${taskIdx}, 'max_iterations', parseInt(this.value))"
|
||||
title="Max Iterations" min="1">
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<button class="btn btn-sm btn-light" onclick="WorkflowBuilder.moveTask(${taskIdx}, -1)"
|
||||
${taskIdx === 0 ? 'disabled' : ''} title="Move up">
|
||||
<i class="bi bi-arrow-up"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-light" onclick="WorkflowBuilder.moveTask(${taskIdx}, 1)"
|
||||
${taskIdx === this.workflow.length - 1 ? 'disabled' : ''} title="Move down">
|
||||
<i class="bi bi-arrow-down"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-danger ms-1" onclick="WorkflowBuilder.removeTask(${taskIdx})"
|
||||
title="Remove task">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="amb-workflow-task-body">
|
||||
<div class="steps-container" id="steps-${taskIdx}"></div>
|
||||
<button class="btn btn-sm btn-outline-primary mt-2" onclick="WorkflowBuilder.addStep(${taskIdx})">
|
||||
<i class="bi bi-plus-lg"></i> Add Step
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
this.container.appendChild(taskCard);
|
||||
|
||||
const stepsContainer = taskCard.querySelector(`#steps-${taskIdx}`);
|
||||
(task.steps || []).forEach((step, stepIdx) => {
|
||||
const stepDiv = this.createStepElement(task, taskIdx, step, stepIdx);
|
||||
stepsContainer.appendChild(stepDiv);
|
||||
});
|
||||
});
|
||||
|
||||
// Add task button
|
||||
const addTaskBtn = document.createElement('button');
|
||||
addTaskBtn.className = 'btn btn-primary';
|
||||
addTaskBtn.innerHTML = '<i class="bi bi-plus-lg"></i> Add Task';
|
||||
addTaskBtn.onclick = () => this.addTask();
|
||||
this.container.appendChild(addTaskBtn);
|
||||
|
||||
this.sync();
|
||||
|
||||
// Reinitialize Choices.js for new elements
|
||||
if (window.ChoicesManager) {
|
||||
window.ChoicesManager.refresh();
|
||||
}
|
||||
},
|
||||
|
||||
createStepElement(task, taskIdx, step, stepIdx) {
|
||||
const stepDiv = document.createElement('div');
|
||||
stepDiv.className = 'amb-workflow-step';
|
||||
|
||||
let fieldsHtml = `
|
||||
<select class="form-select form-select-sm mb-2" data-choices
|
||||
onchange="WorkflowBuilder.updateStepType(${taskIdx}, ${stepIdx}, this.value)">
|
||||
<option value="">Select action type...</option>
|
||||
${Object.entries(this.stepDefinitions).map(([type, def]) =>
|
||||
`<option value="${type}" ${step.type === type ? 'selected' : ''}>${this.escapeHtml(def.label)}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
`;
|
||||
|
||||
if (step.type && this.stepDefinitions[step.type]) {
|
||||
fieldsHtml += '<div class="row g-2 mt-2">';
|
||||
const def = this.stepDefinitions[step.type];
|
||||
Object.entries(def.fields || {}).forEach(([field, fieldDef]) => {
|
||||
const val = step[field] !== undefined ? step[field] : '';
|
||||
const isCheckbox = fieldDef.type === 'checkbox';
|
||||
const suggestions = fieldDef.suggestions || Array.from(this.allSuggestions);
|
||||
|
||||
fieldsHtml += `
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-semibold">${this.escapeHtml(fieldDef.label)}</label>
|
||||
${isCheckbox ? `
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input"
|
||||
${step[field] ? 'checked' : ''}
|
||||
onchange="WorkflowBuilder.updateStepField(${taskIdx}, ${stepIdx}, '${field}', this.checked)">
|
||||
</div>
|
||||
` : `
|
||||
<select class="form-select form-select-sm" data-choices
|
||||
onchange="WorkflowBuilder.updateStepField(${taskIdx}, ${stepIdx}, '${field}', this.value)">
|
||||
<option value="">${this.escapeHtml(fieldDef.default || 'Select...')}</option>
|
||||
${suggestions.map(s =>
|
||||
`<option value="${this.escapeHtml(s)}" ${val === s ? 'selected' : ''}>${this.escapeHtml(s)}</option>`
|
||||
).join('')}
|
||||
</select>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
fieldsHtml += '</div>';
|
||||
}
|
||||
|
||||
stepDiv.innerHTML = `
|
||||
<div class="amb-workflow-step-header">
|
||||
<span class="badge bg-secondary">Step ${stepIdx + 1}</span>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<button class="btn btn-sm btn-link p-0" onclick="WorkflowBuilder.moveStep(${taskIdx}, ${stepIdx}, -1)"
|
||||
${stepIdx === 0 ? 'disabled' : ''} title="Move up">
|
||||
<i class="bi bi-arrow-up"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-link p-0" onclick="WorkflowBuilder.moveStep(${taskIdx}, ${stepIdx}, 1)"
|
||||
${stepIdx === (task.steps || []).length - 1 ? 'disabled' : ''} title="Move down">
|
||||
<i class="bi bi-arrow-down"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-link p-0 text-danger" onclick="WorkflowBuilder.removeStep(${taskIdx}, ${stepIdx})"
|
||||
title="Remove step">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
${fieldsHtml}
|
||||
`;
|
||||
|
||||
return stepDiv;
|
||||
},
|
||||
|
||||
sync() {
|
||||
if (this.textarea) {
|
||||
this.textarea.value = JSON.stringify(this.workflow, null, 2);
|
||||
}
|
||||
},
|
||||
|
||||
toggleRaw() {
|
||||
if (!this.textarea) return;
|
||||
|
||||
this.textarea.classList.toggle('d-none');
|
||||
if (!this.textarea.classList.contains('d-none')) {
|
||||
this.textarea.oninput = () => {
|
||||
try {
|
||||
this.workflow = JSON.parse(this.textarea.value);
|
||||
this.render();
|
||||
} catch (e) {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
updateTask(idx, field, value) {
|
||||
this.workflow[idx][field] = value;
|
||||
if (field === 'type' && value !== 'loop') {
|
||||
delete this.workflow[idx].max_iterations;
|
||||
} else if (field === 'type' && value === 'loop' && !this.workflow[idx].max_iterations) {
|
||||
this.workflow[idx].max_iterations = 10;
|
||||
}
|
||||
this.render();
|
||||
},
|
||||
|
||||
addTask() {
|
||||
this.workflow.push({ name: 'New Task', steps: [] });
|
||||
this.render();
|
||||
},
|
||||
|
||||
removeTask(idx) {
|
||||
if (confirm('Remove this task?')) {
|
||||
this.workflow.splice(idx, 1);
|
||||
this.render();
|
||||
}
|
||||
},
|
||||
|
||||
moveTask(idx, dir) {
|
||||
const target = idx + dir;
|
||||
if (target >= 0 && target < this.workflow.length) {
|
||||
[this.workflow[idx], this.workflow[target]] = [this.workflow[target], this.workflow[idx]];
|
||||
this.render();
|
||||
}
|
||||
},
|
||||
|
||||
updateStepType(taskIdx, stepIdx, type) {
|
||||
const newStep = { type: type };
|
||||
if (this.stepDefinitions[type]) {
|
||||
Object.entries(this.stepDefinitions[type].fields || {}).forEach(([field, fieldDef]) => {
|
||||
newStep[field] = fieldDef.default;
|
||||
});
|
||||
}
|
||||
this.workflow[taskIdx].steps[stepIdx] = newStep;
|
||||
this.render();
|
||||
},
|
||||
|
||||
updateStepField(taskIdx, stepIdx, field, value) {
|
||||
this.workflow[taskIdx].steps[stepIdx][field] = value;
|
||||
this.sync();
|
||||
},
|
||||
|
||||
addStep(taskIdx) {
|
||||
if (!this.workflow[taskIdx].steps) {
|
||||
this.workflow[taskIdx].steps = [];
|
||||
}
|
||||
this.workflow[taskIdx].steps.push({ type: '' });
|
||||
this.render();
|
||||
},
|
||||
|
||||
removeStep(taskIdx, stepIdx) {
|
||||
this.workflow[taskIdx].steps.splice(stepIdx, 1);
|
||||
this.render();
|
||||
},
|
||||
|
||||
moveStep(taskIdx, stepIdx, dir) {
|
||||
const target = stepIdx + dir;
|
||||
const steps = this.workflow[taskIdx].steps;
|
||||
if (target >= 0 && target < steps.length) {
|
||||
[steps[stepIdx], steps[target]] = [steps[target], steps[stepIdx]];
|
||||
this.render();
|
||||
}
|
||||
},
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
};
|
||||
|
||||
// Export globally
|
||||
window.WorkflowBuilder = WorkflowBuilder;
|
||||
74
src/autometabuilder/web/templates/base.html
Normal file
74
src/autometabuilder/web/templates/base.html
Normal file
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}AutoMetabuilder Dashboard{% endblock %}</title>
|
||||
|
||||
<!-- Bootstrap 5.3.3 -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
|
||||
<!-- Bootstrap Icons -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
|
||||
<!-- Choices.js -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/styles/choices.min.css">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body class="amb-layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="amb-sidebar">
|
||||
<div class="amb-sidebar-header">
|
||||
<h5><i class="bi bi-cpu"></i> AutoMetabuilder</h5>
|
||||
</div>
|
||||
|
||||
<nav class="amb-nav">
|
||||
<a href="#dashboard" class="amb-nav-link active" data-section="dashboard">
|
||||
<i class="bi bi-speedometer2"></i> Dashboard
|
||||
</a>
|
||||
<a href="#workflow" class="amb-nav-link" data-section="workflow">
|
||||
<i class="bi bi-diagram-3"></i> Workflow
|
||||
</a>
|
||||
<a href="#prompt" class="amb-nav-link" data-section="prompt">
|
||||
<i class="bi bi-file-text"></i> Prompt
|
||||
</a>
|
||||
<a href="#settings" class="amb-nav-link" data-section="settings">
|
||||
<i class="bi bi-gear"></i> Settings
|
||||
</a>
|
||||
<a href="#translations" class="amb-nav-link" data-section="translations">
|
||||
<i class="bi bi-translate"></i> Translations
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="amb-sidebar-footer">
|
||||
<div class="amb-user">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
<span>{{ username }}</span>
|
||||
</div>
|
||||
<button class="amb-theme-toggle" data-theme-toggle title="Toggle theme">
|
||||
<i class="bi bi-sun-fill"></i>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="amb-main">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<!-- Choices.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/choices.js@10.2.0/public/assets/scripts/choices.min.js"></script>
|
||||
|
||||
<!-- Custom Scripts -->
|
||||
<script src="/static/js/main.js"></script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,381 +1,240 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>AutoMetabuilder Dashboard</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css">
|
||||
</head>
|
||||
<body class="container mt-5">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h1>AutoMetabuilder Dashboard</h1>
|
||||
<span>Logged in as: <strong>{{ username }}</strong></span>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Dashboard Section -->
|
||||
<section id="dashboard" class="amb-section active">
|
||||
<div class="amb-section-header">
|
||||
<h1>Dashboard</h1>
|
||||
<p>Monitor system status and recent activity</p>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-8">
|
||||
<h2>Recent Logs</h2>
|
||||
<pre id="logs" class="bg-light p-3 border" style="max-height: 400px; overflow-y: scroll;">{{ logs }}</pre>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h2>System Status</h2>
|
||||
<div id="status-container">
|
||||
<p>Status:
|
||||
{% if is_running %}
|
||||
<span class="badge bg-warning text-dark">Bot Running...</span>
|
||||
{% else %}
|
||||
<span class="badge bg-success">Idle</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
<p>MVP Milestone:
|
||||
{% if mvp_reached %}
|
||||
<span class="badge bg-primary">Reached ✓</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">In Progress</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="row">
|
||||
<!-- Status Card -->
|
||||
<div class="col-lg-4 col-md-6">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-activity"></i> System Status</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span class="text-muted">Bot Status</span>
|
||||
<div id="status-indicator" class="amb-status {% if is_running %}amb-status-running{% else %}amb-status-idle{% endif %}">
|
||||
<span class="amb-status-dot"></span>
|
||||
{% if is_running %}Running{% else %}Idle{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<span class="text-muted">MVP Milestone</span>
|
||||
<span id="mvp-badge" class="badge {% if mvp_reached %}bg-primary{% else %}bg-secondary{% endif %}">
|
||||
{% if mvp_reached %}
|
||||
<i class="bi bi-check-circle-fill"></i> Reached
|
||||
{% else %}
|
||||
<i class="bi bi-hourglass-split"></i> In Progress
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div id="status-progress" class="progress mt-2" style="display: {% if is_running %}block{% else %}none{% endif %};">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="amb-card-footer">
|
||||
<form action="/run" method="post" class="m-0">
|
||||
<button id="run-btn" type="submit" class="btn btn-danger w-100" {% if is_running %}disabled{% endif %}>
|
||||
<i class="bi bi-play-fill"></i> Run Bot One Iteration
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="/run" method="post" class="mt-2">
|
||||
<button id="run-btn" type="submit" class="btn btn-danger w-100" {% if is_running %}disabled{% endif %}>
|
||||
Run Bot One Iteration
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<h2 class="mt-4">Translations</h2>
|
||||
<ul class="list-group">
|
||||
{% for lang, file in translations.items() %}
|
||||
<li class="list-group-item">{{ lang }} ({{ file }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<form action="/translations" method="post" class="mt-2">
|
||||
<div class="input-group">
|
||||
<input type="text" name="lang" class="form-control" placeholder="New lang code (e.g. de)" list="lang-suggestions">
|
||||
<datalist id="lang-suggestions">
|
||||
{% for lang in metadata.suggestions.languages %}
|
||||
<option value="{{ lang }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
<button class="btn btn-primary" type="submit">Add</button>
|
||||
</div>
|
||||
|
||||
<!-- Logs Card -->
|
||||
<div class="col-lg-8 col-md-6">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-terminal"></i> Recent Logs</h5>
|
||||
</div>
|
||||
<div class="amb-card-body p-0">
|
||||
<pre id="logs" class="amb-logs m-0">{{ logs }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Workflow Section -->
|
||||
<section id="workflow" class="amb-section">
|
||||
<div class="amb-section-header">
|
||||
<h1>Workflow Builder</h1>
|
||||
<p>Configure the bot's task execution workflow</p>
|
||||
</div>
|
||||
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-body">
|
||||
<div id="workflow-builder" class="mb-3">
|
||||
<!-- Visual builder will be rendered here -->
|
||||
</div>
|
||||
<form action="/workflow" method="post" id="workflow-form" data-validate>
|
||||
<textarea id="workflow-content" name="content" class="form-control amb-code-editor d-none" rows="15">{{ workflow_content }}</textarea>
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-save"></i> Save Workflow
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-secondary" onclick="WorkflowBuilder.toggleRaw()">
|
||||
<i class="bi bi-code-slash"></i> Toggle Raw JSON
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<h2>Settings (.env)</h2>
|
||||
<form action="/settings" method="post">
|
||||
<table class="table table-bordered">
|
||||
<!-- Prompt Section -->
|
||||
<section id="prompt" class="amb-section">
|
||||
<div class="amb-section-header">
|
||||
<h1>Prompt Editor</h1>
|
||||
<p>Edit the system prompt configuration (prompt.yml)</p>
|
||||
</div>
|
||||
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-body">
|
||||
<form action="/prompt" method="post" data-validate>
|
||||
<textarea name="content" class="form-control amb-code-editor" rows="20" required>{{ prompt_content }}</textarea>
|
||||
<div class="invalid-feedback">Prompt content is required.</div>
|
||||
<button type="submit" class="btn btn-success mt-3">
|
||||
<i class="bi bi-save"></i> Save Prompt
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings Section -->
|
||||
<section id="settings" class="amb-section">
|
||||
<div class="amb-section-header">
|
||||
<h1>Settings</h1>
|
||||
<p>Manage environment variables (.env)</p>
|
||||
</div>
|
||||
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-body">
|
||||
<form action="/settings" method="post" data-validate>
|
||||
<table class="table table-bordered mb-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th style="width: 40%;">Key</th>
|
||||
<th>Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for key, value in env_vars.items() %}
|
||||
<tr>
|
||||
<td>{{ key }}</td>
|
||||
<td><code>{{ key }}</code></td>
|
||||
<td>
|
||||
<input type="text" name="env_{{ key }}" value="{{ value }}" class="form-control" list="env-value-suggestions">
|
||||
<select name="env_{{ key }}" class="form-select form-select-sm" data-choices>
|
||||
<option value="{{ value }}" selected>{{ value }}</option>
|
||||
{% for val in metadata.suggestions.env_values %}
|
||||
{% if val != value %}
|
||||
<option value="{{ val }}">{{ val }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<tr class="table-light">
|
||||
<td>
|
||||
<input type="text" name="new_env_key" class="form-control" placeholder="NEW_KEY" list="env-key-suggestions">
|
||||
<select name="new_env_key" class="form-select form-select-sm" data-choices data-placeholder="Add new key...">
|
||||
<option value="">Add new key...</option>
|
||||
{% for key in metadata.suggestions.env_keys %}
|
||||
<option value="{{ key }}">{{ key }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="new_env_value" class="form-control" placeholder="New Value" list="env-value-suggestions">
|
||||
<select name="new_env_value" class="form-select form-select-sm" data-choices data-placeholder="Value...">
|
||||
<option value="">Value...</option>
|
||||
{% for val in metadata.suggestions.env_values %}
|
||||
<option value="{{ val }}">{{ val }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<datalist id="env-key-suggestions">
|
||||
{% for key in env_vars.keys() %}
|
||||
<option value="{{ key }}">
|
||||
{% endfor %}
|
||||
{% for key in metadata.suggestions.env_keys %}
|
||||
<option value="{{ key }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
<datalist id="env-value-suggestions">
|
||||
{% for value in env_vars.values() %}
|
||||
<option value="{{ value }}">
|
||||
{% endfor %}
|
||||
{% for val in metadata.suggestions.env_values %}
|
||||
<option value="{{ val }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
<button type="submit" class="btn btn-success">Save Settings</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h2>Prompt (prompt.yml)</h2>
|
||||
<form action="/prompt" method="post">
|
||||
<textarea name="content" class="form-control" rows="15">{{ prompt_content }}</textarea>
|
||||
<button type="submit" class="btn btn-success mt-2">Save Prompt</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-save"></i> Save Settings
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="row mt-4 mb-5">
|
||||
<div class="col-12">
|
||||
<h2>Workflow Builder (workflow.json)</h2>
|
||||
<div id="workflow-builder" class="mb-3">
|
||||
<!-- Visual builder will be rendered here -->
|
||||
</div>
|
||||
<form action="/workflow" method="post" id="workflow-form">
|
||||
<datalist id="task-name-suggestions">
|
||||
{% for name in metadata.suggestions.task_names %}
|
||||
<option value="{{ name }}">
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
<textarea id="workflow-content" name="content" class="form-control d-none" rows="15">{{ workflow_content }}</textarea>
|
||||
<button type="submit" class="btn btn-success mt-2">Save Workflow</button>
|
||||
<button type="button" class="btn btn-secondary mt-2" onclick="toggleRawWorkflow()">Toggle Raw JSON</button>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Translations Section -->
|
||||
<section id="translations" class="amb-section">
|
||||
<div class="amb-section-header">
|
||||
<h1>Translations</h1>
|
||||
<p>Manage language translations</p>
|
||||
</div>
|
||||
<script>
|
||||
let workflow = [];
|
||||
try {
|
||||
workflow = JSON.parse(document.getElementById('workflow-content').value);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse workflow JSON", e);
|
||||
}
|
||||
|
||||
const stepDefinitions = JSON.parse('{{ metadata.step_definitions | tojson | safe }}');
|
||||
|
||||
const allSuggestions = new Set();
|
||||
Object.values(stepDefinitions).forEach(def => {
|
||||
Object.values(def.fields).forEach(f => {
|
||||
if (f.suggestions) f.suggestions.forEach(s => allSuggestions.add(s));
|
||||
});
|
||||
});
|
||||
|
||||
function renderWorkflow() {
|
||||
const container = document.getElementById('workflow-builder');
|
||||
container.innerHTML = '';
|
||||
|
||||
workflow.forEach((task, taskIdx) => {
|
||||
const taskCard = document.createElement('div');
|
||||
taskCard.className = 'card mb-4 border-primary';
|
||||
taskCard.innerHTML = `
|
||||
<div class="card-header bg-primary text-white d-flex justify-content-between align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<input type="text" class="form-control form-control-sm d-inline-block w-auto" value="${task.name || ''}" list="task-name-suggestions" onchange="updateTask(${taskIdx}, 'name', this.value)" placeholder="Task Name">
|
||||
<select class="form-select form-select-sm d-inline-block w-auto ms-2" onchange="updateTask(${taskIdx}, 'type', this.value)">
|
||||
<option value="" ${!task.type ? 'selected' : ''}>Standard Sequence</option>
|
||||
<option value="loop" ${task.type === 'loop' ? 'selected' : ''}>Loop / Iterate</option>
|
||||
</select>
|
||||
${task.type === 'loop' ? `<input type="number" class="form-control form-control-sm d-inline-block w-auto ms-2" value="${task.max_iterations || 1}" onchange="updateTask(${taskIdx}, 'max_iterations', parseInt(this.value))" title="Max Iterations">` : ''}
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-light" onclick="moveTask(${taskIdx}, -1)" ${taskIdx === 0 ? 'disabled' : ''}>↑</button>
|
||||
<button class="btn btn-sm btn-light" onclick="moveTask(${taskIdx}, 1)" ${taskIdx === workflow.length - 1 ? 'disabled' : ''}>↓</button>
|
||||
<button class="btn btn-sm btn-danger ms-2" onclick="removeTask(${taskIdx})">Remove Task</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="steps-${taskIdx}"></div>
|
||||
<button class="btn btn-sm btn-outline-primary mt-2" onclick="addStep(${taskIdx})">+ Add Step</button>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(taskCard);
|
||||
|
||||
const stepsContainer = taskCard.querySelector(`#steps-${taskIdx}`);
|
||||
(task.steps || []).forEach((step, stepIdx) => {
|
||||
const stepDiv = document.createElement('div');
|
||||
stepDiv.className = 'card mb-2 bg-light';
|
||||
|
||||
let fieldsHtml = `
|
||||
<select class="form-select form-select-sm mb-2" onchange="updateStepType(${taskIdx}, ${stepIdx}, this.value)">
|
||||
<option value="">Select Action Type...</option>
|
||||
${Object.entries(stepDefinitions).map(([type, def]) => `<option value="${type}" ${step.type === type ? 'selected' : ''}>${def.label}</option>`).join('')}
|
||||
</select>
|
||||
`;
|
||||
|
||||
if (step.type && stepDefinitions[step.type]) {
|
||||
fieldsHtml += '<div class="row g-2">';
|
||||
const def = stepDefinitions[step.type];
|
||||
Object.entries(def.fields).forEach(([field, fieldDef]) => {
|
||||
const val = step[field] !== undefined ? step[field] : '';
|
||||
const isCheckbox = fieldDef.type === 'checkbox';
|
||||
|
||||
fieldsHtml += `
|
||||
<div class="col-md-6">
|
||||
<label class="small fw-bold">${fieldDef.label}</label>
|
||||
${isCheckbox
|
||||
? `<div class="form-check mt-1"><input type="checkbox" class="form-check-input" ${step[field] ? 'checked' : ''} onchange="updateStepField(${taskIdx}, ${stepIdx}, '${field}', this.checked)"></div>`
|
||||
: `
|
||||
<input type="text" class="form-control form-control-sm" value="${val}" list="suggestions-${taskIdx}-${stepIdx}-${field}" onchange="updateStepField(${taskIdx}, ${stepIdx}, '${field}', this.value)" placeholder="${fieldDef.default || ''}">
|
||||
<datalist id="suggestions-${taskIdx}-${stepIdx}-${field}">
|
||||
${(fieldDef.suggestions || Array.from(allSuggestions)).map(s => `<option value="${s}">`).join('')}
|
||||
</datalist>
|
||||
`
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
fieldsHtml += '</div>';
|
||||
}
|
||||
|
||||
stepDiv.innerHTML = `
|
||||
<div class="card-body p-2">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<span class="badge bg-secondary">Step ${stepIdx + 1}</span>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-link p-0 text-decoration-none" onclick="moveStep(${taskIdx}, ${stepIdx}, -1)" ${stepIdx === 0 ? 'disabled' : ''}>↑</button>
|
||||
<button class="btn btn-sm btn-link p-0 text-decoration-none ms-1" onclick="moveStep(${taskIdx}, ${stepIdx}, 1)" ${stepIdx === (task.steps.length - 1) ? 'disabled' : ''}>↓</button>
|
||||
<button class="btn btn-sm btn-link p-0 text-danger text-decoration-none ms-2" onclick="removeStep(${taskIdx}, ${stepIdx})">✕</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-list-check"></i> Available Languages</h5>
|
||||
</div>
|
||||
<div class="amb-card-body p-0">
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for lang, file in translations.items() %}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-globe me-2"></i>
|
||||
<strong>{{ lang }}</strong>
|
||||
</div>
|
||||
${fieldsHtml}
|
||||
<span class="badge bg-light text-dark">{{ file }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="amb-card">
|
||||
<div class="amb-card-header">
|
||||
<h5><i class="bi bi-plus-circle"></i> Add Translation</h5>
|
||||
</div>
|
||||
<div class="amb-card-body">
|
||||
<form action="/translations" method="post" data-validate>
|
||||
<div class="amb-form-group">
|
||||
<label class="amb-form-label">
|
||||
Language Code <span class="amb-required">*</span>
|
||||
</label>
|
||||
<select name="lang" class="form-select" data-choices data-placeholder="Select language..." required>
|
||||
<option value="">Select language...</option>
|
||||
{% for lang in metadata.suggestions.languages %}
|
||||
<option value="{{ lang }}">{{ lang }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="invalid-feedback">Please select a language code.</div>
|
||||
</div>
|
||||
`;
|
||||
stepsContainer.appendChild(stepDiv);
|
||||
});
|
||||
});
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-plus-lg"></i> Add Translation
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
const addTaskBtn = document.createElement('button');
|
||||
addTaskBtn.className = 'btn btn-primary mb-5';
|
||||
addTaskBtn.textContent = '+ Add Task';
|
||||
addTaskBtn.onclick = addTask;
|
||||
container.appendChild(addTaskBtn);
|
||||
|
||||
syncWorkflow();
|
||||
}
|
||||
|
||||
function syncWorkflow() {
|
||||
document.getElementById('workflow-content').value = JSON.stringify(workflow, null, 2);
|
||||
}
|
||||
|
||||
function toggleRawWorkflow() {
|
||||
const textarea = document.getElementById('workflow-content');
|
||||
textarea.classList.toggle('d-none');
|
||||
if (!textarea.classList.contains('d-none')) {
|
||||
textarea.oninput = function() {
|
||||
try {
|
||||
workflow = JSON.parse(this.value);
|
||||
renderWorkflow();
|
||||
} catch (e) {}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function updateTask(idx, field, value) {
|
||||
workflow[idx][field] = value;
|
||||
if (field === 'type' && value !== 'loop') {
|
||||
delete workflow[idx].max_iterations;
|
||||
} else if (field === 'type' && value === 'loop' && !workflow[idx].max_iterations) {
|
||||
workflow[idx].max_iterations = 10;
|
||||
}
|
||||
renderWorkflow();
|
||||
}
|
||||
|
||||
function addTask() {
|
||||
workflow.push({ name: 'New Task', steps: [] });
|
||||
renderWorkflow();
|
||||
}
|
||||
|
||||
function removeTask(idx) {
|
||||
if (confirm('Remove this task?')) {
|
||||
workflow.splice(idx, 1);
|
||||
renderWorkflow();
|
||||
}
|
||||
}
|
||||
|
||||
function moveTask(idx, dir) {
|
||||
const target = idx + dir;
|
||||
if (target >= 0 && target < workflow.length) {
|
||||
[workflow[idx], workflow[target]] = [workflow[target], workflow[idx]];
|
||||
renderWorkflow();
|
||||
}
|
||||
}
|
||||
|
||||
function updateStepType(taskIdx, stepIdx, type) {
|
||||
const newStep = { type: type };
|
||||
if (stepDefinitions[type]) {
|
||||
Object.entries(stepDefinitions[type].fields).forEach(([field, fieldDef]) => {
|
||||
newStep[field] = fieldDef.default;
|
||||
});
|
||||
}
|
||||
workflow[taskIdx].steps[stepIdx] = newStep;
|
||||
renderWorkflow();
|
||||
}
|
||||
|
||||
function updateStepField(taskIdx, stepIdx, field, value) {
|
||||
workflow[taskIdx].steps[stepIdx][field] = value;
|
||||
syncWorkflow();
|
||||
}
|
||||
|
||||
function addStep(taskIdx) {
|
||||
if (!workflow[taskIdx].steps) workflow[taskIdx].steps = [];
|
||||
workflow[taskIdx].steps.push({ type: '' });
|
||||
renderWorkflow();
|
||||
}
|
||||
|
||||
function removeStep(taskIdx, stepIdx) {
|
||||
workflow[taskIdx].steps.splice(stepIdx, 1);
|
||||
renderWorkflow();
|
||||
}
|
||||
|
||||
function moveStep(taskIdx, stepIdx, dir) {
|
||||
const target = stepIdx + dir;
|
||||
const steps = workflow[taskIdx].steps;
|
||||
if (target >= 0 && target < steps.length) {
|
||||
[steps[stepIdx], steps[target]] = [steps[target], steps[stepIdx]];
|
||||
renderWorkflow();
|
||||
}
|
||||
}
|
||||
|
||||
renderWorkflow();
|
||||
|
||||
async function refreshLogs() {
|
||||
try {
|
||||
const response = await fetch('/api/logs');
|
||||
const data = await response.json();
|
||||
const logsPre = document.getElementById('logs');
|
||||
const wasAtBottom = logsPre.scrollHeight - logsPre.clientHeight <= logsPre.scrollTop + 1;
|
||||
logsPre.textContent = data.logs;
|
||||
if (wasAtBottom) {
|
||||
logsPre.scrollTop = logsPre.scrollHeight;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
const data = await response.json();
|
||||
const statusContainer = document.getElementById('status-container');
|
||||
const runBtn = document.getElementById('run-btn');
|
||||
|
||||
if (data.is_running) {
|
||||
statusContainer.innerHTML = `
|
||||
<p>Status: <span class="badge bg-warning text-dark">Bot Running...</span></p>
|
||||
<p>MVP Milestone: ${data.mvp_reached ? '<span class="badge bg-primary">Reached ✓</span>' : '<span class="badge bg-secondary">In Progress</span>'}</p>
|
||||
<div class="progress mt-2">
|
||||
<div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100" style="width: 100%"></div>
|
||||
</div>
|
||||
`;
|
||||
runBtn.disabled = true;
|
||||
} else {
|
||||
statusContainer.innerHTML = `
|
||||
<p>Status: <span class="badge bg-success">Idle</span></p>
|
||||
<p>MVP Milestone: ${data.mvp_reached ? '<span class="badge bg-primary">Reached ✓</span>' : '<span class="badge bg-secondary">In Progress</span>'}</p>
|
||||
`;
|
||||
runBtn.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(refreshLogs, 2000);
|
||||
setInterval(refreshStatus, 2000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
{% block scripts %}
|
||||
<script src="/static/js/workflow.js"></script>
|
||||
<script>
|
||||
// Initialize workflow builder with step definitions from metadata
|
||||
const stepDefinitions = {{ metadata.step_definitions | tojson | safe }};
|
||||
WorkflowBuilder.init('workflow-builder', 'workflow-content', stepDefinitions);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,124 +1,206 @@
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
def test_login_and_dashboard(page: Page, server: str):
|
||||
# Go to the server
|
||||
page.goto(server)
|
||||
|
||||
# We should be prompted for basic auth
|
||||
# Playwright handles basic auth via context or by encoding it in the URL
|
||||
# or we can use the page.authenticate method
|
||||
|
||||
# Alternative: use URL with credentials
|
||||
# Go to the server with auth
|
||||
auth_url = server.replace("http://", "http://testuser:testpass@")
|
||||
page.goto(auth_url)
|
||||
|
||||
# Check if we are on the dashboard
|
||||
expect(page.locator("h1")).to_contain_text("AutoMetabuilder Dashboard")
|
||||
expect(page.locator("text=Logged in as: testuser")).to_be_visible()
|
||||
|
||||
# Check if we are on the dashboard - select h1 in the active section
|
||||
expect(page.locator("#dashboard.active h1")).to_contain_text("Dashboard")
|
||||
# User info is now in sidebar footer
|
||||
expect(page.locator(".amb-sidebar-footer")).to_contain_text("testuser")
|
||||
|
||||
def test_run_bot_mock(page: Page, server: str):
|
||||
auth_url = server.replace("http://", "http://testuser:testpass@")
|
||||
page.goto(auth_url)
|
||||
|
||||
|
||||
# Click run button
|
||||
page.click("button:has-text('Run Bot')")
|
||||
|
||||
# Status should change to "Bot Running..."
|
||||
expect(page.locator(".badge.bg-warning")).to_contain_text("Bot Running")
|
||||
|
||||
# Wait for it to finish (mock takes 5 seconds)
|
||||
page.wait_for_timeout(6000)
|
||||
|
||||
# Refresh
|
||||
page.reload()
|
||||
expect(page.locator(".badge.bg-success")).to_contain_text("Idle")
|
||||
|
||||
# Wait for status to update (uses AJAX polling every 2s)
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
# Status should change to "Running"
|
||||
expect(page.locator("#status-indicator")).to_contain_text("Running")
|
||||
|
||||
# Wait for it to finish (mock takes 5 seconds, add buffer for polling)
|
||||
page.wait_for_timeout(8000)
|
||||
|
||||
# Status should return to Idle - allow longer timeout for polling
|
||||
expect(page.locator("#status-indicator")).to_contain_text("Idle", timeout=15000)
|
||||
|
||||
def test_update_prompt(page: Page, server: str):
|
||||
auth_url = server.replace("http://", "http://testuser:testpass@")
|
||||
page.goto(auth_url)
|
||||
|
||||
# Find prompt textarea
|
||||
textarea = page.locator("textarea[name='content']").first
|
||||
|
||||
# Navigate to prompt section
|
||||
page.click("[data-section='prompt']")
|
||||
page.wait_for_selector("#prompt.active")
|
||||
|
||||
# Find prompt textarea - specifically in the prompt section
|
||||
textarea = page.locator("#prompt textarea[name='content']")
|
||||
original_content = textarea.input_value()
|
||||
|
||||
|
||||
new_content = original_content + "\n# Test Comment"
|
||||
textarea.fill(new_content)
|
||||
|
||||
# Click update prompt
|
||||
page.click("button:has-text('Save Prompt')")
|
||||
|
||||
|
||||
# Click save prompt
|
||||
page.click("#prompt button:has-text('Save Prompt')")
|
||||
|
||||
# Verify it updated
|
||||
page.reload()
|
||||
expect(page.locator("textarea[name='content']").first).to_have_value(new_content)
|
||||
page.click("[data-section='prompt']")
|
||||
page.wait_for_selector("#prompt.active")
|
||||
expect(page.locator("#prompt textarea[name='content']")).to_have_value(new_content)
|
||||
|
||||
def test_update_settings(page: Page, server: str):
|
||||
auth_url = server.replace("http://", "http://testuser:testpass@")
|
||||
page.goto(auth_url)
|
||||
|
||||
# Add a new setting
|
||||
page.fill("input[name='new_env_key']", "TEST_SETTING")
|
||||
page.fill("input[name='new_env_value']", "test_value")
|
||||
|
||||
page.click("button:has-text('Save Settings')")
|
||||
|
||||
|
||||
# Navigate to settings section
|
||||
page.click("[data-section='settings']")
|
||||
page.wait_for_selector("#settings.active")
|
||||
|
||||
# Wait for Choices.js to initialize
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# Add a new setting using Choices.js select
|
||||
# Click on the outer .choices wrapper (first match only)
|
||||
key_choices = page.locator("#settings select[name='new_env_key']").locator("xpath=ancestor::div[@class='choices' or contains(@class, 'choices ')]").first
|
||||
key_choices.click()
|
||||
page.keyboard.type("GITHUB_TOKEN")
|
||||
page.keyboard.press("Enter")
|
||||
|
||||
# For new_env_value
|
||||
value_choices = page.locator("#settings select[name='new_env_value']").locator("xpath=ancestor::div[@class='choices' or contains(@class, 'choices ')]").first
|
||||
value_choices.click()
|
||||
page.keyboard.type("DEBUG")
|
||||
page.keyboard.press("Enter")
|
||||
|
||||
page.click("#settings button:has-text('Save Settings')")
|
||||
|
||||
# Verify it appeared in the table
|
||||
page.reload()
|
||||
expect(page.locator("input[name='env_TEST_SETTING']")).to_have_value("test_value")
|
||||
page.click("[data-section='settings']")
|
||||
page.wait_for_selector("#settings.active")
|
||||
expect(page.locator("#settings code:has-text('GITHUB_TOKEN')")).to_be_visible()
|
||||
|
||||
def test_all_text_inputs_have_autocomplete(page: Page, server: str):
|
||||
def test_navigation_sections(page: Page, server: str):
|
||||
"""Test that sidebar navigation works correctly"""
|
||||
auth_url = server.replace("http://", "http://testuser:testpass@")
|
||||
page.goto(auth_url)
|
||||
|
||||
# Wait for the workflow builder to render
|
||||
page.wait_for_selector("#workflow-builder")
|
||||
|
||||
# Select all text inputs
|
||||
inputs = page.locator("input[type='text']")
|
||||
|
||||
count = inputs.count()
|
||||
assert count > 0, "No text inputs found on the page"
|
||||
|
||||
for i in range(count):
|
||||
input_element = inputs.nth(i)
|
||||
# Check if the 'list' attribute is present and not empty
|
||||
list_attr = input_element.get_attribute("list")
|
||||
input_name = input_element.get_attribute("name") or input_element.get_attribute("placeholder") or f"index {i}"
|
||||
assert list_attr, f"Input '{input_name}' does not have a 'list' attribute for autocomplete"
|
||||
|
||||
# Check if the corresponding datalist exists
|
||||
datalist = page.locator(f"datalist#{list_attr}")
|
||||
expect(datalist).to_be_attached(), f"Datalist '{list_attr}' for input '{input_name}' is missing"
|
||||
|
||||
# Dashboard should be active by default
|
||||
expect(page.locator("#dashboard")).to_have_class(re.compile(r"active"))
|
||||
|
||||
# Navigate to each section
|
||||
sections = ["workflow", "prompt", "settings", "translations"]
|
||||
for section in sections:
|
||||
page.click(f"[data-section='{section}']")
|
||||
page.wait_for_timeout(100)
|
||||
expect(page.locator(f"#{section}")).to_have_class(re.compile(r"active"))
|
||||
# Previous section should no longer be active
|
||||
expect(page.locator("#dashboard")).not_to_have_class(re.compile(r"active"))
|
||||
|
||||
def test_theme_toggle(page: Page, server: str):
|
||||
"""Test dark mode toggle functionality"""
|
||||
auth_url = server.replace("http://", "http://testuser:testpass@")
|
||||
page.goto(auth_url)
|
||||
|
||||
# Get initial theme
|
||||
html = page.locator("html")
|
||||
initial_theme = html.get_attribute("data-theme")
|
||||
|
||||
# Click toggle
|
||||
page.click("[data-theme-toggle]")
|
||||
page.wait_for_timeout(100)
|
||||
|
||||
# Theme should have changed
|
||||
new_theme = html.get_attribute("data-theme")
|
||||
assert new_theme != initial_theme, f"Theme did not change from {initial_theme}"
|
||||
|
||||
# Toggle back
|
||||
page.click("[data-theme-toggle]")
|
||||
page.wait_for_timeout(100)
|
||||
final_theme = html.get_attribute("data-theme")
|
||||
assert final_theme == initial_theme, f"Theme should return to {initial_theme}"
|
||||
|
||||
def test_choices_dropdowns_exist(page: Page, server: str):
|
||||
"""Test that Choices.js dropdowns are initialized"""
|
||||
auth_url = server.replace("http://", "http://testuser:testpass@")
|
||||
page.goto(auth_url)
|
||||
|
||||
# Navigate to translations to find language dropdown
|
||||
page.click("[data-section='translations']")
|
||||
page.wait_for_selector("#translations.active")
|
||||
|
||||
# Wait for Choices.js to initialize
|
||||
page.wait_for_timeout(1000)
|
||||
|
||||
# Check that data-choices elements have been enhanced
|
||||
choices_elements = page.locator("#translations [data-choices]")
|
||||
count = choices_elements.count()
|
||||
assert count > 0, "No Choices.js elements found in translations section"
|
||||
|
||||
# Verify Choices.js wrapper exists - use CSS selector for the outer wrapper
|
||||
choices_wrapper = page.locator("#translations div.choices[data-type='select-one']").first
|
||||
expect(choices_wrapper).to_be_visible()
|
||||
|
||||
def test_autocomplete_values_from_json(page: Page, server: str):
|
||||
"""Test that dropdown options are populated from metadata.json"""
|
||||
# Load metadata.json
|
||||
metadata_path = os.path.join(os.path.dirname(__file__), "../../src/autometabuilder/metadata.json")
|
||||
with open(metadata_path, 'r') as f:
|
||||
metadata = json.load(f)
|
||||
|
||||
|
||||
auth_url = server.replace("http://", "http://testuser:testpass@")
|
||||
page.goto(auth_url)
|
||||
|
||||
# Verify lang-suggestions
|
||||
for lang in metadata["suggestions"]["languages"]:
|
||||
expect(page.locator(f"#lang-suggestions option[value='{lang}']").first).to_be_attached()
|
||||
|
||||
# Verify env-key-suggestions
|
||||
for key in metadata["suggestions"]["env_keys"]:
|
||||
expect(page.locator(f"#env-key-suggestions option[value='{key}']").first).to_be_attached()
|
||||
|
||||
# Verify env-value-suggestions
|
||||
for val in metadata["suggestions"]["env_values"]:
|
||||
expect(page.locator(f"#env-value-suggestions option[value='{val}']").first).to_be_attached()
|
||||
|
||||
# Verify task-name-suggestions
|
||||
for name in metadata["suggestions"]["task_names"]:
|
||||
expect(page.locator(f"#task-name-suggestions option[value='{name}']").first).to_be_attached()
|
||||
|
||||
# Verify workflow builder step suggestions
|
||||
# We need to add a task and a step first
|
||||
# This might be complex but let's at least check if allSuggestions was populated
|
||||
# and used in some datalist.
|
||||
# The existing test_all_text_inputs_have_autocomplete already checks if datalists are attached.
|
||||
# Navigate to translations section to check language options
|
||||
page.click("[data-section='translations']")
|
||||
page.wait_for_selector("#translations.active")
|
||||
|
||||
# Verify language options in select
|
||||
lang_select = page.locator("#translations select[name='lang']")
|
||||
for lang in metadata["suggestions"]["languages"]:
|
||||
expect(lang_select.locator(f"option[value='{lang}']")).to_be_attached()
|
||||
|
||||
# Navigate to settings to check env options
|
||||
page.click("[data-section='settings']")
|
||||
page.wait_for_selector("#settings.active")
|
||||
|
||||
# Verify env key options
|
||||
key_select = page.locator("#settings select[name='new_env_key']")
|
||||
for key in metadata["suggestions"]["env_keys"]:
|
||||
expect(key_select.locator(f"option[value='{key}']")).to_be_attached()
|
||||
|
||||
# Verify env value options
|
||||
value_select = page.locator("#settings select[name='new_env_value']")
|
||||
for val in metadata["suggestions"]["env_values"]:
|
||||
expect(value_select.locator(f"option[value='{val}']")).to_be_attached()
|
||||
|
||||
def test_workflow_builder_renders(page: Page, server: str):
|
||||
"""Test that workflow builder initializes and renders"""
|
||||
auth_url = server.replace("http://", "http://testuser:testpass@")
|
||||
page.goto(auth_url)
|
||||
|
||||
# Navigate to workflow section
|
||||
page.click("[data-section='workflow']")
|
||||
page.wait_for_selector("#workflow.active")
|
||||
|
||||
# Wait for workflow builder to render
|
||||
page.wait_for_selector("#workflow-builder")
|
||||
|
||||
# Should have at least the "Add Task" button
|
||||
expect(page.locator("#workflow-builder button:has-text('Add Task')")).to_be_visible()
|
||||
|
||||
# Toggle raw JSON should work
|
||||
page.click("#workflow button:has-text('Toggle Raw JSON')")
|
||||
expect(page.locator("#workflow-content")).to_be_visible()
|
||||
|
||||
page.click("#workflow button:has-text('Toggle Raw JSON')")
|
||||
expect(page.locator("#workflow-content")).not_to_be_visible()
|
||||
|
||||
Reference in New Issue
Block a user