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:
1
backend/src/api/routes/__init__.py
Executable file
1
backend/src/api/routes/__init__.py
Executable file
@@ -0,0 +1 @@
|
||||
from . import plugins, tasks, settings
|
||||
42
backend/src/api/routes/plugins.py
Normal file → Executable file
42
backend/src/api/routes/plugins.py
Normal file → Executable file
@@ -1,22 +1,22 @@
|
||||
# [DEF:PluginsRouter:Module]
|
||||
# @SEMANTICS: api, router, plugins, list
|
||||
# @PURPOSE: Defines the FastAPI router for plugin-related endpoints, allowing clients to list available plugins.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: Depends on the PluginLoader and PluginConfig. It is included by the main app.
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ...core.plugin_base import PluginConfig
|
||||
from ...dependencies import get_plugin_loader
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=List[PluginConfig])
|
||||
async def list_plugins(
|
||||
plugin_loader = Depends(get_plugin_loader)
|
||||
):
|
||||
"""
|
||||
Retrieve a list of all available plugins.
|
||||
"""
|
||||
return plugin_loader.get_all_plugin_configs()
|
||||
# [DEF:PluginsRouter:Module]
|
||||
# @SEMANTICS: api, router, plugins, list
|
||||
# @PURPOSE: Defines the FastAPI router for plugin-related endpoints, allowing clients to list available plugins.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: Depends on the PluginLoader and PluginConfig. It is included by the main app.
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from ...core.plugin_base import PluginConfig
|
||||
from ...dependencies import get_plugin_loader
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/", response_model=List[PluginConfig])
|
||||
async def list_plugins(
|
||||
plugin_loader = Depends(get_plugin_loader)
|
||||
):
|
||||
"""
|
||||
Retrieve a list of all available plugins.
|
||||
"""
|
||||
return plugin_loader.get_all_plugin_configs()
|
||||
# [/DEF]
|
||||
185
backend/src/api/routes/settings.py
Executable file
185
backend/src/api/routes/settings.py
Executable file
@@ -0,0 +1,185 @@
|
||||
# [DEF:SettingsRouter:Module]
|
||||
#
|
||||
# @SEMANTICS: settings, api, router, fastapi
|
||||
# @PURPOSE: Provides API endpoints for managing application settings and Superset environments.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: DEPENDS_ON -> ConfigManager
|
||||
# @RELATION: DEPENDS_ON -> ConfigModels
|
||||
#
|
||||
# @INVARIANT: All settings changes must be persisted via ConfigManager.
|
||||
# @PUBLIC_API: router
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from typing import List
|
||||
from ...core.config_models import AppConfig, Environment, GlobalSettings
|
||||
from ...dependencies import get_config_manager
|
||||
from ...core.config_manager import ConfigManager
|
||||
from ...core.logger import logger
|
||||
from superset_tool.client import SupersetClient
|
||||
from superset_tool.models import SupersetConfig
|
||||
import os
|
||||
# [/SECTION]
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# [DEF:get_settings:Function]
|
||||
# @PURPOSE: Retrieves all application settings.
|
||||
# @RETURN: AppConfig - The current configuration.
|
||||
@router.get("/", response_model=AppConfig)
|
||||
async def get_settings(config_manager: ConfigManager = Depends(get_config_manager)):
|
||||
logger.info("[get_settings][Entry] Fetching all settings")
|
||||
config = config_manager.get_config().copy(deep=True)
|
||||
# Mask passwords
|
||||
for env in config.environments:
|
||||
if env.password:
|
||||
env.password = "********"
|
||||
return config
|
||||
# [/DEF:get_settings]
|
||||
|
||||
# [DEF:update_global_settings:Function]
|
||||
# @PURPOSE: Updates global application settings.
|
||||
# @PARAM: settings (GlobalSettings) - The new global settings.
|
||||
# @RETURN: GlobalSettings - The updated settings.
|
||||
@router.patch("/global", response_model=GlobalSettings)
|
||||
async def update_global_settings(
|
||||
settings: GlobalSettings,
|
||||
config_manager: ConfigManager = Depends(get_config_manager)
|
||||
):
|
||||
logger.info("[update_global_settings][Entry] Updating global settings")
|
||||
config_manager.update_global_settings(settings)
|
||||
return settings
|
||||
# [/DEF:update_global_settings]
|
||||
|
||||
# [DEF:get_environments:Function]
|
||||
# @PURPOSE: Lists all configured Superset environments.
|
||||
# @RETURN: List[Environment] - List of environments.
|
||||
@router.get("/environments", response_model=List[Environment])
|
||||
async def get_environments(config_manager: ConfigManager = Depends(get_config_manager)):
|
||||
logger.info("[get_environments][Entry] Fetching environments")
|
||||
return config_manager.get_environments()
|
||||
# [/DEF:get_environments]
|
||||
|
||||
# [DEF:add_environment:Function]
|
||||
# @PURPOSE: Adds a new Superset environment.
|
||||
# @PARAM: env (Environment) - The environment to add.
|
||||
# @RETURN: Environment - The added environment.
|
||||
@router.post("/environments", response_model=Environment)
|
||||
async def add_environment(
|
||||
env: Environment,
|
||||
config_manager: ConfigManager = Depends(get_config_manager)
|
||||
):
|
||||
logger.info(f"[add_environment][Entry] Adding environment {env.id}")
|
||||
config_manager.add_environment(env)
|
||||
return env
|
||||
# [/DEF:add_environment]
|
||||
|
||||
# [DEF:update_environment:Function]
|
||||
# @PURPOSE: Updates an existing Superset environment.
|
||||
# @PARAM: id (str) - The ID of the environment to update.
|
||||
# @PARAM: env (Environment) - The updated environment data.
|
||||
# @RETURN: Environment - The updated environment.
|
||||
@router.put("/environments/{id}", response_model=Environment)
|
||||
async def update_environment(
|
||||
id: str,
|
||||
env: Environment,
|
||||
config_manager: ConfigManager = Depends(get_config_manager)
|
||||
):
|
||||
logger.info(f"[update_environment][Entry] Updating environment {id}")
|
||||
if config_manager.update_environment(id, env):
|
||||
return env
|
||||
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
|
||||
# [/DEF:update_environment]
|
||||
|
||||
# [DEF:delete_environment:Function]
|
||||
# @PURPOSE: Deletes a Superset environment.
|
||||
# @PARAM: id (str) - The ID of the environment to delete.
|
||||
@router.delete("/environments/{id}")
|
||||
async def delete_environment(
|
||||
id: str,
|
||||
config_manager: ConfigManager = Depends(get_config_manager)
|
||||
):
|
||||
logger.info(f"[delete_environment][Entry] Deleting environment {id}")
|
||||
config_manager.delete_environment(id)
|
||||
return {"message": f"Environment {id} deleted"}
|
||||
# [/DEF:delete_environment]
|
||||
|
||||
# [DEF:test_environment_connection:Function]
|
||||
# @PURPOSE: Tests the connection to a Superset environment.
|
||||
# @PARAM: id (str) - The ID of the environment to test.
|
||||
# @RETURN: dict - Success message or error.
|
||||
@router.post("/environments/{id}/test")
|
||||
async def test_environment_connection(
|
||||
id: str,
|
||||
config_manager: ConfigManager = Depends(get_config_manager)
|
||||
):
|
||||
logger.info(f"[test_environment_connection][Entry] Testing environment {id}")
|
||||
|
||||
# Find environment
|
||||
env = next((e for e in config_manager.get_environments() if e.id == id), None)
|
||||
if not env:
|
||||
raise HTTPException(status_code=404, detail=f"Environment {id} not found")
|
||||
|
||||
try:
|
||||
# Create SupersetConfig
|
||||
# Note: SupersetConfig expects 'auth' dict with specific keys
|
||||
superset_config = SupersetConfig(
|
||||
env=env.name,
|
||||
base_url=env.url,
|
||||
auth={
|
||||
"provider": "db", # Defaulting to db for now
|
||||
"username": env.username,
|
||||
"password": env.password,
|
||||
"refresh": "true"
|
||||
}
|
||||
)
|
||||
|
||||
# Initialize client (this will trigger authentication)
|
||||
client = SupersetClient(config=superset_config)
|
||||
|
||||
# Try a simple request to verify
|
||||
client.get_dashboards(query={"page_size": 1})
|
||||
|
||||
logger.info(f"[test_environment_connection][Coherence:OK] Connection successful for {id}")
|
||||
return {"status": "success", "message": "Connection successful"}
|
||||
except Exception as e:
|
||||
logger.error(f"[test_environment_connection][Coherence:Failed] Connection failed for {id}: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
# [/DEF:test_environment_connection]
|
||||
|
||||
# [DEF:validate_backup_path:Function]
|
||||
# @PURPOSE: Validates if a backup path exists and is writable.
|
||||
# @PARAM: path (str) - The path to validate.
|
||||
# @RETURN: dict - Validation result.
|
||||
@router.post("/validate-path")
|
||||
async def validate_backup_path(path_data: dict):
|
||||
path = path_data.get("path")
|
||||
if not path:
|
||||
raise HTTPException(status_code=400, detail="Path is required")
|
||||
|
||||
logger.info(f"[validate_backup_path][Entry] Validating path: {path}")
|
||||
|
||||
p = os.path.abspath(path)
|
||||
exists = os.path.exists(p)
|
||||
writable = os.access(p, os.W_OK) if exists else os.access(os.path.dirname(p), os.W_OK)
|
||||
|
||||
if not exists:
|
||||
# Try to create it
|
||||
try:
|
||||
os.makedirs(p, exist_ok=True)
|
||||
exists = True
|
||||
writable = os.access(p, os.W_OK)
|
||||
logger.info(f"[validate_backup_path][Action] Created directory: {p}")
|
||||
except Exception as e:
|
||||
logger.error(f"[validate_backup_path][Coherence:Failed] Failed to create directory: {e}")
|
||||
return {"status": "error", "message": f"Path does not exist and could not be created: {e}"}
|
||||
|
||||
if not writable:
|
||||
logger.warning(f"[validate_backup_path][Coherence:Failed] Path not writable: {p}")
|
||||
return {"status": "error", "message": "Path is not writable"}
|
||||
|
||||
logger.info(f"[validate_backup_path][Coherence:OK] Path valid: {p}")
|
||||
return {"status": "success", "message": "Path is valid and writable"}
|
||||
# [/DEF:validate_backup_path]
|
||||
|
||||
# [/DEF:SettingsRouter]
|
||||
112
backend/src/api/routes/tasks.py
Normal file → Executable file
112
backend/src/api/routes/tasks.py
Normal file → Executable file
@@ -1,57 +1,57 @@
|
||||
# [DEF:TasksRouter:Module]
|
||||
# @SEMANTICS: api, router, tasks, create, list, get
|
||||
# @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: Depends on the TaskManager. It is included by the main app.
|
||||
from typing import List, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...core.task_manager import TaskManager, Task
|
||||
from ...dependencies import get_task_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class CreateTaskRequest(BaseModel):
|
||||
plugin_id: str
|
||||
params: Dict[str, Any]
|
||||
|
||||
@router.post("/", response_model=Task, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
request: CreateTaskRequest,
|
||||
task_manager: TaskManager = Depends(get_task_manager)
|
||||
):
|
||||
"""
|
||||
Create and start a new task for a given plugin.
|
||||
"""
|
||||
try:
|
||||
task = await task_manager.create_task(
|
||||
plugin_id=request.plugin_id,
|
||||
params=request.params
|
||||
)
|
||||
return task
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
|
||||
@router.get("/", response_model=List[Task])
|
||||
async def list_tasks(
|
||||
task_manager: TaskManager = Depends(get_task_manager)
|
||||
):
|
||||
"""
|
||||
Retrieve a list of all tasks.
|
||||
"""
|
||||
return task_manager.get_all_tasks()
|
||||
|
||||
@router.get("/{task_id}", response_model=Task)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
task_manager: TaskManager = Depends(get_task_manager)
|
||||
):
|
||||
"""
|
||||
Retrieve the details of a specific task.
|
||||
"""
|
||||
task = task_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||
return task
|
||||
# [DEF:TasksRouter:Module]
|
||||
# @SEMANTICS: api, router, tasks, create, list, get
|
||||
# @PURPOSE: Defines the FastAPI router for task-related endpoints, allowing clients to create, list, and get the status of tasks.
|
||||
# @LAYER: UI (API)
|
||||
# @RELATION: Depends on the TaskManager. It is included by the main app.
|
||||
from typing import List, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...core.task_manager import TaskManager, Task
|
||||
from ...dependencies import get_task_manager
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
class CreateTaskRequest(BaseModel):
|
||||
plugin_id: str
|
||||
params: Dict[str, Any]
|
||||
|
||||
@router.post("/", response_model=Task, status_code=status.HTTP_201_CREATED)
|
||||
async def create_task(
|
||||
request: CreateTaskRequest,
|
||||
task_manager: TaskManager = Depends(get_task_manager)
|
||||
):
|
||||
"""
|
||||
Create and start a new task for a given plugin.
|
||||
"""
|
||||
try:
|
||||
task = await task_manager.create_task(
|
||||
plugin_id=request.plugin_id,
|
||||
params=request.params
|
||||
)
|
||||
return task
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
|
||||
|
||||
@router.get("/", response_model=List[Task])
|
||||
async def list_tasks(
|
||||
task_manager: TaskManager = Depends(get_task_manager)
|
||||
):
|
||||
"""
|
||||
Retrieve a list of all tasks.
|
||||
"""
|
||||
return task_manager.get_all_tasks()
|
||||
|
||||
@router.get("/{task_id}", response_model=Task)
|
||||
async def get_task(
|
||||
task_id: str,
|
||||
task_manager: TaskManager = Depends(get_task_manager)
|
||||
):
|
||||
"""
|
||||
Retrieve the details of a specific task.
|
||||
"""
|
||||
task = task_manager.get_task(task_id)
|
||||
if not task:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
|
||||
return task
|
||||
# [/DEF]
|
||||
Reference in New Issue
Block a user