Password promt

This commit is contained in:
2025-12-30 17:21:12 +03:00
parent 4c9d554432
commit a032fe8457
20 changed files with 834 additions and 176 deletions

25
.kilocodemodes Normal file
View File

@@ -0,0 +1,25 @@
customModes:
- slug: tester
name: Tester
description: QA and Plan Verification Specialist
roleDefinition: >-
You are Kilo Code, acting as a QA and Verification Specialist. Your primary goal is to validate that the project implementation aligns strictly with the defined specifications and task plans.
Your responsibilities include:
- Reading and analyzing task plans and specifications (typically in the `specs/` directory).
- Verifying that implemented code matches the requirements.
- Executing tests and validating system behavior via CLI or Browser.
- Updating the status of tasks in the plan files (e.g., marking checkboxes [x]) as they are verified.
- Identifying and reporting missing features or bugs.
whenToUse: >-
Use this mode when you need to audit the progress of a project, verify completed tasks against the plan, run quality assurance checks, or update the status of task lists in specification documents.
groups:
- read
- edit
- command
- browser
- mcp
customInstructions: >-
1. Always begin by loading the relevant plan or task list from the `specs/` directory.
2. Do not assume a task is done just because it is checked; verify the code or functionality first if asked to audit.
3. When updating task lists, ensure you only mark items as complete if you have verified them.

Binary file not shown.

View File

@@ -55,12 +55,17 @@ async def execute_migration(selection: DashboardSelection, config_manager=Depend
# Create migration task with debug logging # Create migration task with debug logging
from ...core.logger import logger from ...core.logger import logger
logger.info(f"Creating migration task with selection: {selection.dict()}")
# Include replace_db_config in the task parameters
task_params = selection.dict()
task_params['replace_db_config'] = selection.replace_db_config
logger.info(f"Creating migration task with params: {task_params}")
logger.info(f"Available environments: {env_ids}") logger.info(f"Available environments: {env_ids}")
logger.info(f"Source env: {selection.source_env_id}, Target env: {selection.target_env_id}") logger.info(f"Source env: {selection.source_env_id}, Target env: {selection.target_env_id}")
try: try:
task = await task_manager.create_task("superset-migration", selection.dict()) task = await task_manager.create_task("superset-migration", task_params)
logger.info(f"Task created successfully: {task.id}") logger.info(f"Task created successfully: {task.id}")
return {"task_id": task.id, "message": "Migration initiated"} return {"task_id": task.id, "message": "Migration initiated"}
except Exception as e: except Exception as e:

View File

@@ -41,12 +41,15 @@ async def create_task(
@router.get("/", response_model=List[Task]) @router.get("/", response_model=List[Task])
async def list_tasks( async def list_tasks(
limit: int = 10,
offset: int = 0,
status: Optional[TaskStatus] = None,
task_manager: TaskManager = Depends(get_task_manager) task_manager: TaskManager = Depends(get_task_manager)
): ):
""" """
Retrieve a list of all tasks. Retrieve a list of tasks with pagination and optional status filter.
""" """
return task_manager.get_all_tasks() return task_manager.get_tasks(limit=limit, offset=offset, status=status)
@router.get("/{task_id}", response_model=Task) @router.get("/{task_id}", response_model=Task)
async def get_task( async def get_task(
@@ -61,6 +64,19 @@ async def get_task(
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Task not found")
return task return task
@router.get("/{task_id}/logs", response_model=List[LogEntry])
async def get_task_logs(
task_id: str,
task_manager: TaskManager = Depends(get_task_manager)
):
"""
Retrieve logs for 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_manager.get_task_logs(task_id)
@router.post("/{task_id}/resolve", response_model=Task) @router.post("/{task_id}/resolve", response_model=Task)
async def resolve_task( async def resolve_task(
task_id: str, task_id: str,
@@ -90,4 +106,15 @@ async def resume_task(
return task_manager.get_task(task_id) return task_manager.get_task(task_id)
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e)) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/", status_code=status.HTTP_204_NO_CONTENT)
async def clear_tasks(
status: Optional[TaskStatus] = None,
task_manager: TaskManager = Depends(get_task_manager)
):
"""
Clear tasks matching the status filter. If no filter, clears all non-running tasks.
"""
task_manager.clear_tasks(status)
return
# [/DEF] # [/DEF]

View File

@@ -63,16 +63,30 @@ async def websocket_endpoint(websocket: WebSocket, task_id: str):
task_manager = get_task_manager() task_manager = get_task_manager()
queue = await task_manager.subscribe_logs(task_id) queue = await task_manager.subscribe_logs(task_id)
try: try:
# Send initial logs if any # Stream new logs
logger.info(f"Starting log stream for task {task_id}")
# Send initial logs first to build context
initial_logs = task_manager.get_task_logs(task_id) initial_logs = task_manager.get_task_logs(task_id)
for log_entry in initial_logs: for log_entry in initial_logs:
# Convert datetime to string for JSON serialization
log_dict = log_entry.dict() log_dict = log_entry.dict()
log_dict['timestamp'] = log_dict['timestamp'].isoformat() log_dict['timestamp'] = log_dict['timestamp'].isoformat()
await websocket.send_json(log_dict) await websocket.send_json(log_dict)
# Stream new logs # Force a check for AWAITING_INPUT status immediately upon connection
logger.info(f"Starting log stream for task {task_id}") # This ensures that if the task is already waiting when the user connects, they get the prompt.
task = task_manager.get_task(task_id)
if task and task.status == "AWAITING_INPUT" and task.input_request:
# Construct a synthetic log entry to trigger the frontend handler
# This is a bit of a hack but avoids changing the websocket protocol significantly
synthetic_log = {
"timestamp": task.logs[-1].timestamp.isoformat() if task.logs else "2024-01-01T00:00:00",
"level": "INFO",
"message": "Task paused for user input (Connection Re-established)",
"context": {"input_request": task.input_request}
}
await websocket.send_json(synthetic_log)
while True: while True:
log_entry = await queue.get() log_entry = await queue.get()
log_dict = log_entry.dict() log_dict = log_entry.dict()
@@ -84,7 +98,9 @@ async def websocket_endpoint(websocket: WebSocket, task_id: str):
if "Task completed successfully" in log_entry.message or "Task failed" in log_entry.message: if "Task completed successfully" in log_entry.message or "Task failed" in log_entry.message:
# Wait a bit to ensure client receives the last message # Wait a bit to ensure client receives the last message
await asyncio.sleep(2) await asyncio.sleep(2)
break # DO NOT BREAK here - allow client to keep connection open if they want to review logs
# or until they disconnect. Breaking closes the socket immediately.
# break
except WebSocketDisconnect: except WebSocketDisconnect:
logger.info(f"WebSocket connection disconnected for task {task_id}") logger.info(f"WebSocket connection disconnected for task {task_id}")

View File

@@ -72,6 +72,8 @@ class ConfigManager:
return config return config
except Exception as e: except Exception as e:
logger.error(f"[_load_config][Coherence:Failed] Error loading config: {e}") logger.error(f"[_load_config][Coherence:Failed] Error loading config: {e}")
# Fallback but try to preserve existing settings if possible?
# For now, return default to be safe, but log the error prominently.
return AppConfig( return AppConfig(
environments=[], environments=[],
settings=GlobalSettings(backup_path="backups") settings=GlobalSettings(backup_path="backups")

View File

@@ -35,6 +35,11 @@ class GlobalSettings(BaseModel):
backup_path: str backup_path: str
default_environment_id: Optional[str] = None default_environment_id: Optional[str] = None
logging: LoggingConfig = Field(default_factory=LoggingConfig) logging: LoggingConfig = Field(default_factory=LoggingConfig)
# Task retention settings
task_retention_days: int = 30
task_retention_limit: int = 100
pagination_limit: int = 10
# [/DEF:GlobalSettings] # [/DEF:GlobalSettings]
# [DEF:AppConfig:DataClass] # [DEF:AppConfig:DataClass]

View File

@@ -43,6 +43,9 @@ class TaskManager:
except RuntimeError: except RuntimeError:
self.loop = asyncio.get_event_loop() self.loop = asyncio.get_event_loop()
self.task_futures: Dict[str, asyncio.Future] = {} self.task_futures: Dict[str, asyncio.Future] = {}
# Load persisted tasks on startup
self.load_persisted_tasks()
# [/DEF:TaskManager.__init__:Function] # [/DEF:TaskManager.__init__:Function]
# [DEF:TaskManager.create_task:Function] # [DEF:TaskManager.create_task:Function]
@@ -328,8 +331,49 @@ class TaskManager:
if task_id in self.task_futures: if task_id in self.task_futures:
self.task_futures[task_id].set_result(True) self.task_futures[task_id].set_result(True)
self.persist_awaiting_input_tasks() # Remove from persistence as it's no longer awaiting input
self.persistence_service.delete_tasks([task_id])
# [/DEF:TaskManager.resume_task_with_password:Function] # [/DEF:TaskManager.resume_task_with_password:Function]
# [DEF:TaskManager.clear_tasks:Function]
# @PURPOSE: Clears tasks based on status filter.
# @PARAM: status (Optional[TaskStatus]) - Filter by task status.
# @RETURN: int - Number of tasks cleared.
def clear_tasks(self, status: Optional[TaskStatus] = None) -> int:
with belief_scope("TaskManager.clear_tasks"):
tasks_to_remove = []
for task_id, task in list(self.tasks.items()):
# If status is provided, match it.
# If status is None, match everything EXCEPT RUNNING (unless they are awaiting input/mapping which are technically running but paused?)
# Actually, AWAITING_INPUT and AWAITING_MAPPING are distinct statuses in TaskStatus enum.
# RUNNING is active execution.
should_remove = False
if status:
if task.status == status:
should_remove = True
else:
# Clear all non-active tasks
if task.status not in [TaskStatus.RUNNING]:
should_remove = True
if should_remove:
tasks_to_remove.append(task_id)
for tid in tasks_to_remove:
# Cancel future if exists (e.g. for AWAITING_INPUT/MAPPING)
if tid in self.task_futures:
self.task_futures[tid].cancel()
del self.task_futures[tid]
del self.tasks[tid]
# Remove from persistence
self.persistence_service.delete_tasks(tasks_to_remove)
logger.info(f"Cleared {len(tasks_to_remove)} tasks.")
return len(tasks_to_remove)
# [/DEF:TaskManager.clear_tasks:Function]
# [/DEF:TaskManager:Class] # [/DEF:TaskManager:Class]
# [/DEF:TaskManagerModule:Module] # [/DEF:TaskManagerModule:Module]

View File

@@ -122,6 +122,21 @@ class TaskPersistenceService:
return loaded_tasks return loaded_tasks
# [/DEF:TaskPersistenceService.load_tasks:Function] # [/DEF:TaskPersistenceService.load_tasks:Function]
# [DEF:TaskPersistenceService.delete_tasks:Function]
# @PURPOSE: Deletes specific tasks from the database.
# @PARAM: task_ids (List[str]) - List of task IDs to delete.
def delete_tasks(self, task_ids: List[str]) -> None:
if not task_ids:
return
with belief_scope("TaskPersistenceService.delete_tasks"):
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
placeholders = ', '.join('?' for _ in task_ids)
cursor.execute(f"DELETE FROM persistent_tasks WHERE id IN ({placeholders})", task_ids)
conn.commit()
conn.close()
# [/DEF:TaskPersistenceService.delete_tasks:Function]
# [/DEF:TaskPersistenceService:Class] # [/DEF:TaskPersistenceService:Class]
# [/DEF:TaskPersistenceModule:Module] # [/DEF:TaskPersistenceModule:Module]

View File

@@ -22,6 +22,7 @@ class DashboardSelection(BaseModel):
selected_ids: List[int] selected_ids: List[int]
source_env_id: str source_env_id: str
target_env_id: str target_env_id: str
replace_db_config: bool = False
# [/DEF:DashboardSelection] # [/DEF:DashboardSelection]
# [/DEF:backend.src.models.dashboard] # [/DEF:backend.src.models.dashboard]

View File

@@ -100,7 +100,31 @@ class MigrationPlugin(PluginBase):
from_db_id = params.get("from_db_id") from_db_id = params.get("from_db_id")
to_db_id = params.get("to_db_id") to_db_id = params.get("to_db_id")
logger = SupersetLogger(log_dir=Path.cwd() / "logs", console=True) # [DEF:MigrationPlugin.execute:Action]
# @PURPOSE: Execute the migration logic with proper task logging.
task_id = params.get("_task_id")
from ..dependencies import get_task_manager
tm = get_task_manager()
class TaskLoggerProxy(SupersetLogger):
def __init__(self):
# Initialize parent with dummy values since we override methods
super().__init__(console=False)
def debug(self, msg, *args, extra=None, **kwargs):
if task_id: tm._add_log(task_id, "DEBUG", msg, extra or {})
def info(self, msg, *args, extra=None, **kwargs):
if task_id: tm._add_log(task_id, "INFO", msg, extra or {})
def warning(self, msg, *args, extra=None, **kwargs):
if task_id: tm._add_log(task_id, "WARNING", msg, extra or {})
def error(self, msg, *args, extra=None, **kwargs):
if task_id: tm._add_log(task_id, "ERROR", msg, extra or {})
def critical(self, msg, *args, extra=None, **kwargs):
if task_id: tm._add_log(task_id, "ERROR", msg, extra or {})
def exception(self, msg, *args, **kwargs):
if task_id: tm._add_log(task_id, "ERROR", msg, {"exception": True})
logger = TaskLoggerProxy()
logger.info(f"[MigrationPlugin][Entry] Starting migration task.") logger.info(f"[MigrationPlugin][Entry] Starting migration task.")
logger.info(f"[MigrationPlugin][Action] Params: {params}") logger.info(f"[MigrationPlugin][Action] Params: {params}")
@@ -188,10 +212,7 @@ class MigrationPlugin(PluginBase):
if not success and replace_db_config: if not success and replace_db_config:
# Signal missing mapping and wait (only if we care about mappings) # Signal missing mapping and wait (only if we care about mappings)
task_id = params.get("_task_id")
if task_id: if task_id:
from ..dependencies import get_task_manager
tm = get_task_manager()
logger.info(f"[MigrationPlugin][Action] Pausing for missing mapping in task {task_id}") logger.info(f"[MigrationPlugin][Action] Pausing for missing mapping in task {task_id}")
# In a real scenario, we'd pass the missing DB info to the frontend # In a real scenario, we'd pass the missing DB info to the frontend
# For this task, we'll just simulate the wait # For this task, we'll just simulate the wait
@@ -220,16 +241,25 @@ class MigrationPlugin(PluginBase):
except Exception as exc: except Exception as exc:
# Check for password error # Check for password error
error_msg = str(exc) error_msg = str(exc)
if "Must provide a password for the database" in error_msg: # The error message from Superset is often a JSON string inside a string.
# Extract database name (assuming format: "Must provide a password for the database 'PostgreSQL'") # We need to robustly detect the password requirement.
import re # Typical error: "Error importing dashboard: databases/PostgreSQL.yaml: {'_schema': ['Must provide a password for the database']}"
match = re.search(r"database '([^']+)'", error_msg)
db_name = match.group(1) if match else "unknown"
# Get task manager if "Must provide a password for the database" in error_msg:
from ..dependencies import get_task_manager # Extract database name
tm = get_task_manager() # Try to find "databases/DBNAME.yaml" pattern
task_id = params.get("_task_id") import re
db_name = "unknown"
match = re.search(r"databases/([^.]+)\.yaml", error_msg)
if match:
db_name = match.group(1)
else:
# Fallback: try to find 'database 'NAME'' pattern
match_alt = re.search(r"database '([^']+)'", error_msg)
if match_alt:
db_name = match_alt.group(1)
logger.warning(f"[MigrationPlugin][Action] Detected missing password for database: {db_name}")
if task_id: if task_id:
input_request = { input_request = {
@@ -251,6 +281,9 @@ class MigrationPlugin(PluginBase):
logger.info(f"[MigrationPlugin][Action] Retrying import for {title} with provided passwords.") logger.info(f"[MigrationPlugin][Action] Retrying import for {title} with provided passwords.")
to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, passwords=passwords) to_c.import_dashboard(file_name=tmp_new_zip, dash_id=dash_id, dash_slug=dash_slug, passwords=passwords)
logger.info(f"[MigrationPlugin][Success] Dashboard {title} imported after password injection.") logger.info(f"[MigrationPlugin][Success] Dashboard {title} imported after password injection.")
# Clear passwords from params after use for security
if "passwords" in task.params:
del task.params["passwords"]
continue continue
logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True) logger.error(f"[MigrationPlugin][Failure] Failed to migrate dashboard {title}: {exc}", exc_info=True)

View File

@@ -24,7 +24,7 @@ export const options = {
app: ({ head, body, assets, nonce, env }) => "<!DOCTYPE html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<link rel=\"icon\" href=\"" + assets + "/favicon.png\" />\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\t\t" + head + "\n\t</head>\n\t<body data-sveltekit-preload-data=\"hover\">\n\t\t<div style=\"display: contents\">" + body + "</div>\n\t</body>\n</html>\n", app: ({ head, body, assets, nonce, env }) => "<!DOCTYPE html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<link rel=\"icon\" href=\"" + assets + "/favicon.png\" />\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\t\t" + head + "\n\t</head>\n\t<body data-sveltekit-preload-data=\"hover\">\n\t\t<div style=\"display: contents\">" + body + "</div>\n\t</body>\n</html>\n",
error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n" error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n"
}, },
version_hash: "oj9twc" version_hash: "1v1g3pu"
}; };
export async function get_hooks() { export async function get_hooks() {

View File

@@ -21,6 +21,13 @@
if (!res.ok) throw new Error('Failed to fetch tasks'); if (!res.ok) throw new Error('Failed to fetch tasks');
tasks = await res.json(); tasks = await res.json();
// [DEBUG] Check for tasks requiring attention
tasks.forEach(t => {
if (t.status === 'AWAITING_MAPPING' || t.status === 'AWAITING_INPUT') {
console.log(`[TaskHistory] Task ${t.id} is in state ${t.status}. Input required: ${t.input_required}`);
}
});
// Update selected task if it exists in the list (for status updates) // Update selected task if it exists in the list (for status updates)
if ($selectedTask) { if ($selectedTask) {
const updatedTask = tasks.find(t => t.id === $selectedTask.id); const updatedTask = tasks.find(t => t.id === $selectedTask.id);
@@ -35,8 +42,37 @@
} }
} }
function selectTask(task) { async function clearTasks(status = null) {
selectedTask.set(task); if (!confirm('Are you sure you want to clear tasks?')) return;
try {
let url = '/api/tasks';
const params = new URLSearchParams();
if (status) params.append('status', status);
const res = await fetch(`${url}?${params.toString()}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to clear tasks');
await fetchTasks();
} catch (e) {
error = e.message;
}
}
async function selectTask(task) {
try {
// Fetch the full task details (including logs) before setting it as selected
const res = await fetch(`/api/tasks/${task.id}`);
if (res.ok) {
const fullTask = await res.json();
selectedTask.set(fullTask);
} else {
// Fallback to the list version if fetch fails
selectedTask.set(task);
}
} catch (e) {
console.error("Failed to fetch full task details:", e);
selectedTask.set(task);
}
} }
function getStatusColor(status) { function getStatusColor(status) {
@@ -65,12 +101,29 @@
<h3 class="text-lg leading-6 font-medium text-gray-900"> <h3 class="text-lg leading-6 font-medium text-gray-900">
Recent Tasks Recent Tasks
</h3> </h3>
<button <div class="flex space-x-4 items-center">
on:click={fetchTasks} <div class="relative inline-block text-left group">
class="text-sm text-indigo-600 hover:text-indigo-900 focus:outline-none" <button class="text-sm text-red-600 hover:text-red-900 focus:outline-none flex items-center py-2">
> Clear Tasks
Refresh <svg class="ml-1 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
</button> </button>
<!-- Added a transparent bridge to prevent menu closing when moving cursor -->
<div class="absolute h-2 w-full top-full left-0"></div>
<div class="origin-top-right absolute right-0 mt-2 w-48 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5 focus:outline-none hidden group-hover:block z-50">
<div class="py-1">
<button on:click={() => clearTasks()} class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Clear All Non-Running</button>
<button on:click={() => clearTasks('FAILED')} class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Clear Failed</button>
<button on:click={() => clearTasks('AWAITING_INPUT')} class="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">Clear Awaiting Input</button>
</div>
</div>
</div>
<button
on:click={fetchTasks}
class="text-sm text-indigo-600 hover:text-indigo-900 focus:outline-none"
>
Refresh
</button>
</div>
</div> </div>
{#if loading && tasks.length === 0} {#if loading && tasks.length === 0}

View File

@@ -0,0 +1,153 @@
<!-- [DEF:TaskLogViewer:Component] -->
<!--
@SEMANTICS: task, log, viewer, modal
@PURPOSE: Displays detailed logs for a specific task in a modal.
@LAYER: UI
@RELATION: USES -> frontend/src/lib/api.js (inferred)
-->
<script>
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { getTaskLogs } from '../services/taskService.js';
export let show = false;
export let taskId = null;
export let taskStatus = null; // To know if we should poll
const dispatch = createEventDispatcher();
let logs = [];
let loading = false;
let error = "";
let interval;
let autoScroll = true;
let logContainer;
async function fetchLogs() {
if (!taskId) return;
try {
logs = await getTaskLogs(taskId);
if (autoScroll) {
scrollToBottom();
}
} catch (e) {
error = e.message;
} finally {
loading = false;
}
}
function scrollToBottom() {
if (logContainer) {
setTimeout(() => {
logContainer.scrollTop = logContainer.scrollHeight;
}, 0);
}
}
function handleScroll() {
if (!logContainer) return;
// If user scrolls up, disable auto-scroll
const { scrollTop, scrollHeight, clientHeight } = logContainer;
const atBottom = scrollHeight - scrollTop - clientHeight < 50;
autoScroll = atBottom;
}
function close() {
dispatch('close');
show = false;
}
function getLogLevelColor(level) {
switch (level) {
case 'INFO': return 'text-blue-600';
case 'WARNING': return 'text-yellow-600';
case 'ERROR': return 'text-red-600';
case 'DEBUG': return 'text-gray-500';
default: return 'text-gray-800';
}
}
// React to changes in show/taskId
$: if (show && taskId) {
logs = [];
loading = true;
error = "";
fetchLogs();
// Poll if task is running
if (taskStatus === 'RUNNING' || taskStatus === 'AWAITING_INPUT' || taskStatus === 'AWAITING_MAPPING') {
interval = setInterval(fetchLogs, 3000);
}
} else {
if (interval) clearInterval(interval);
}
onDestroy(() => {
if (interval) clearInterval(interval);
});
</script>
{#if show}
<div class="fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<!-- Background overlay -->
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" aria-hidden="true" on:click={close}></div>
<span class="hidden sm:inline-block sm:align-middle sm:h-screen" aria-hidden="true">&#8203;</span>
<div class="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-4xl sm:w-full">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900 flex justify-between items-center" id="modal-title">
<span>Task Logs <span class="text-sm text-gray-500 font-normal">({taskId})</span></span>
<button on:click={fetchLogs} class="text-sm text-indigo-600 hover:text-indigo-900">Refresh</button>
</h3>
<div class="mt-4 border rounded-md bg-gray-50 p-4 h-96 overflow-y-auto font-mono text-sm"
bind:this={logContainer}
on:scroll={handleScroll}>
{#if loading && logs.length === 0}
<p class="text-gray-500 text-center">Loading logs...</p>
{:else if error}
<p class="text-red-500 text-center">{error}</p>
{:else if logs.length === 0}
<p class="text-gray-500 text-center">No logs available.</p>
{:else}
{#each logs as log}
<div class="mb-1 hover:bg-gray-100 p-1 rounded">
<span class="text-gray-400 text-xs mr-2">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span class="font-bold text-xs mr-2 w-16 inline-block {getLogLevelColor(log.level)}">
[{log.level}]
</span>
<span class="text-gray-800 break-words">
{log.message}
</span>
{#if log.context}
<div class="ml-24 text-xs text-gray-500 mt-1 bg-gray-100 p-1 rounded overflow-x-auto">
<pre>{JSON.stringify(log.context, null, 2)}</pre>
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
on:click={close}
>
Close
</button>
</div>
</div>
</div>
</div>
{/if}
<!-- [/DEF:TaskLogViewer] -->

View File

@@ -93,6 +93,17 @@
} }
}; };
// Check if task is already awaiting input (e.g. when re-selecting task)
// We use the 'task' variable from the outer scope (connect function)
if (task && task.status === 'AWAITING_INPUT' && task.input_request && task.input_request.type === 'database_password') {
connectionStatus = 'awaiting_input';
passwordPromptData = {
databases: task.input_request.databases || [],
errorMessage: task.input_request.error_message || ''
};
showPasswordPrompt = true;
}
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('[TaskRunner][Coherence:Failed] WebSocket error:', error); console.error('[TaskRunner][Coherence:Failed] WebSocket error:', error);
connectionStatus = 'disconnected'; connectionStatus = 'disconnected';
@@ -221,7 +232,15 @@
clearTimeout(reconnectTimeout); clearTimeout(reconnectTimeout);
reconnectAttempts = 0; reconnectAttempts = 0;
connectionStatus = 'disconnected'; connectionStatus = 'disconnected';
taskLogs.set([]);
// Initialize logs from the task object if available
if (task.logs && Array.isArray(task.logs)) {
console.log(`[TaskRunner] Loaded ${task.logs.length} existing logs.`);
taskLogs.set(task.logs);
} else {
taskLogs.set([]);
}
connect(); connect();
} }
}); });
@@ -275,18 +294,46 @@
</div> </div>
</div> </div>
<div class="bg-gray-900 text-white font-mono text-sm p-4 rounded-md h-96 overflow-y-auto relative"> <!-- Task Info Section -->
<div class="mb-4 bg-gray-50 p-3 rounded text-sm border border-gray-200">
<details open>
<summary class="cursor-pointer font-medium text-gray-700 focus:outline-none hover:text-indigo-600">Task Details & Parameters</summary>
<div class="mt-2 pl-2 border-l-2 border-indigo-200">
<div class="grid grid-cols-2 gap-2 mb-2">
<div><span class="font-semibold">ID:</span> <span class="text-gray-600">{$selectedTask.id}</span></div>
<div><span class="font-semibold">Status:</span> <span class="text-gray-600">{$selectedTask.status}</span></div>
<div><span class="font-semibold">Started:</span> <span class="text-gray-600">{new Date($selectedTask.started_at || $selectedTask.created_at || Date.now()).toLocaleString()}</span></div>
<div><span class="font-semibold">Plugin:</span> <span class="text-gray-600">{$selectedTask.plugin_id}</span></div>
</div>
<div class="mt-1">
<span class="font-semibold">Parameters:</span>
<pre class="text-xs bg-gray-100 p-2 rounded mt-1 overflow-x-auto border border-gray-200">{JSON.stringify($selectedTask.params, null, 2)}</pre>
</div>
</div>
</details>
</div>
<div class="bg-gray-900 text-white font-mono text-sm p-4 rounded-md h-96 overflow-y-auto relative shadow-inner">
{#if $taskLogs.length === 0}
<div class="text-gray-500 italic text-center mt-10">No logs available for this task.</div>
{/if}
{#each $taskLogs as log} {#each $taskLogs as log}
<div> <div class="hover:bg-gray-800 px-1 rounded">
<span class="text-gray-400">{new Date(log.timestamp).toLocaleTimeString()}</span> <span class="text-gray-500 select-none text-xs w-20 inline-block">{new Date(log.timestamp).toLocaleTimeString()}</span>
<span class="{log.level === 'ERROR' ? 'text-red-500' : 'text-green-400'}">[{log.level}]</span> <span class="{log.level === 'ERROR' ? 'text-red-500 font-bold' : log.level === 'WARNING' ? 'text-yellow-400' : 'text-green-400'} w-16 inline-block">[{log.level}]</span>
<span>{log.message}</span> <span>{log.message}</span>
{#if log.context}
<details class="ml-24">
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-300">Context</summary>
<pre class="text-xs text-gray-400 pl-2 border-l border-gray-700 mt-1">{JSON.stringify(log.context, null, 2)}</pre>
</details>
{/if}
</div> </div>
{/each} {/each}
{#if waitingForData} {#if waitingForData && connectionStatus === 'connected'}
<div class="text-gray-500 italic mt-2 animate-pulse"> <div class="text-gray-500 italic mt-2 animate-pulse border-t border-gray-800 pt-2">
Waiting for data... Waiting for new logs...
</div> </div>
{/if} {/if}
</div> </div>

View File

@@ -14,8 +14,12 @@
import EnvSelector from '../../components/EnvSelector.svelte'; import EnvSelector from '../../components/EnvSelector.svelte';
import DashboardGrid from '../../components/DashboardGrid.svelte'; import DashboardGrid from '../../components/DashboardGrid.svelte';
import MappingTable from '../../components/MappingTable.svelte'; import MappingTable from '../../components/MappingTable.svelte';
import MissingMappingModal from '../../components/MissingMappingModal.svelte'; import TaskRunner from '../../components/TaskRunner.svelte';
import TaskHistory from '../../components/TaskHistory.svelte'; import TaskHistory from '../../components/TaskHistory.svelte';
import TaskLogViewer from '../../components/TaskLogViewer.svelte';
import PasswordPrompt from '../../components/PasswordPrompt.svelte';
import { selectedTask } from '../../lib/stores.js';
import { resumeTask } from '../../services/taskService.js';
import type { DashboardMetadata, DashboardSelection } from '../../types/dashboard'; import type { DashboardMetadata, DashboardSelection } from '../../types/dashboard';
// [/SECTION] // [/SECTION]
@@ -33,6 +37,15 @@
let mappings: any[] = []; let mappings: any[] = [];
let suggestions: any[] = []; let suggestions: any[] = [];
let fetchingDbs = false; let fetchingDbs = false;
// UI State for Modals
let showLogViewer = false;
let logViewerTaskId: string | null = null;
let logViewerTaskStatus: string | null = null;
let showPasswordPrompt = false;
let passwordPromptDatabases: string[] = [];
let passwordPromptErrorMessage = "";
// [/SECTION] // [/SECTION]
// [DEF:fetchEnvironments:Function] // [DEF:fetchEnvironments:Function]
@@ -147,6 +160,50 @@
} }
// [/DEF:handleMappingUpdate] // [/DEF:handleMappingUpdate]
// [DEF:handleViewLogs:Function]
function handleViewLogs(event: CustomEvent) {
const task = event.detail;
logViewerTaskId = task.id;
logViewerTaskStatus = task.status;
showLogViewer = true;
}
// [/DEF:handleViewLogs]
// [DEF:handlePasswordPrompt:Function]
// This is triggered by TaskRunner or TaskHistory when a task needs input
// For now, we rely on the WebSocket or manual check.
// Ideally, TaskHistory or TaskRunner emits an event when input is needed.
// Or we watch selectedTask.
$: if ($selectedTask && $selectedTask.status === 'AWAITING_INPUT' && $selectedTask.input_request) {
const req = $selectedTask.input_request;
if (req.type === 'database_password') {
passwordPromptDatabases = req.databases || [];
passwordPromptErrorMessage = req.error_message || "";
showPasswordPrompt = true;
}
} else if (!$selectedTask || $selectedTask.status !== 'AWAITING_INPUT') {
// Close prompt if task is no longer waiting (e.g. resumed)
// But only if we are viewing this task.
// showPasswordPrompt = false;
// Actually, don't auto-close, let the user or success handler close it.
}
async function handleResumeMigration(event: CustomEvent) {
if (!$selectedTask) return;
const { passwords } = event.detail;
try {
await resumeTask($selectedTask.id, passwords);
showPasswordPrompt = false;
// Task status update will be handled by store/websocket
} catch (e) {
console.error("Failed to resume task:", e);
passwordPromptErrorMessage = e.message;
// Keep prompt open
}
}
// [DEF:startMigration:Function] // [DEF:startMigration:Function]
/** /**
* @purpose Starts the migration process. * @purpose Starts the migration process.
@@ -171,7 +228,8 @@
const selection: DashboardSelection = { const selection: DashboardSelection = {
selected_ids: selectedDashboardIds, selected_ids: selectedDashboardIds,
source_env_id: sourceEnvId, source_env_id: sourceEnvId,
target_env_id: targetEnvId target_env_id: targetEnvId,
replace_db_config: replaceDb
}; };
console.log(`[MigrationDashboard][Action] Starting migration with selection:`, selection); console.log(`[MigrationDashboard][Action] Starting migration with selection:`, selection);
const response = await fetch('/api/migration/execute', { const response = await fetch('/api/migration/execute', {
@@ -183,7 +241,30 @@
if (!response.ok) throw new Error(`Failed to start migration: ${response.status} ${response.statusText}`); if (!response.ok) throw new Error(`Failed to start migration: ${response.status} ${response.statusText}`);
const result = await response.json(); const result = await response.json();
console.log(`[MigrationDashboard][Action] Migration started: ${result.task_id} - ${result.message}`); console.log(`[MigrationDashboard][Action] Migration started: ${result.task_id} - ${result.message}`);
// TODO: Show success message or redirect to task status
// Wait a brief moment for the backend to ensure the task is retrievable
await new Promise(r => setTimeout(r, 500));
// Fetch full task details and switch to TaskRunner view
try {
const taskRes = await fetch(`/api/tasks/${result.task_id}`);
if (taskRes.ok) {
const task = await taskRes.json();
selectedTask.set(task);
} else {
// Fallback: create a temporary task object to switch view immediately
console.warn("Could not fetch task details immediately, using placeholder.");
selectedTask.set({
id: result.task_id,
plugin_id: 'superset-migration',
status: 'RUNNING',
logs: [],
params: {}
});
}
} catch (fetchErr) {
console.error("Failed to fetch new task details:", fetchErr);
}
} catch (e) { } catch (e) {
console.error(`[MigrationDashboard][Failure] Migration failed:`, e); console.error(`[MigrationDashboard][Failure] Migration failed:`, e);
error = e.message; error = e.message;
@@ -196,90 +277,119 @@
<div class="max-w-4xl mx-auto p-6"> <div class="max-w-4xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Migration Dashboard</h1> <h1 class="text-2xl font-bold mb-6">Migration Dashboard</h1>
<TaskHistory /> <TaskHistory on:viewLogs={handleViewLogs} />
{#if loading} {#if $selectedTask}
<p>Loading environments...</p> <div class="mt-6">
{:else if error} <TaskRunner />
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4"> <button
{error} on:click={() => selectedTask.set(null)}
class="mt-4 inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
>
Back to New Migration
</button>
</div> </div>
{/if} {:else}
{#if loading}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8"> <p>Loading environments...</p>
<EnvSelector {:else if error}
label="Source Environment" <div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
bind:selectedId={sourceEnvId} {error}
{environments} </div>
/>
<EnvSelector
label="Target Environment"
bind:selectedId={targetEnvId}
{environments}
/>
</div>
<!-- [DEF:DashboardSelectionSection] -->
<div class="mb-8">
<h2 class="text-lg font-medium mb-4">Select Dashboards</h2>
{#if sourceEnvId}
<DashboardGrid
{dashboards}
bind:selectedIds={selectedDashboardIds}
/>
{:else}
<p class="text-gray-500 italic">Select a source environment to view dashboards.</p>
{/if} {/if}
</div>
<!-- [/DEF:DashboardSelectionSection] -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<EnvSelector
label="Source Environment"
bind:selectedId={sourceEnvId}
{environments}
/>
<EnvSelector
label="Target Environment"
bind:selectedId={targetEnvId}
{environments}
/>
</div>
<div class="flex items-center mb-4"> <!-- [DEF:DashboardSelectionSection] -->
<input <div class="mb-8">
id="replace-db" <h2 class="text-lg font-medium mb-4">Select Dashboards</h2>
type="checkbox"
bind:checked={replaceDb}
on:change={() => { if (replaceDb && sourceDatabases.length === 0) fetchDatabases(); }}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label for="replace-db" class="ml-2 block text-sm text-gray-900">
Replace Database (Apply Mappings)
</label>
</div>
{#if replaceDb} {#if sourceEnvId}
<div class="mb-8 p-4 border rounded-md bg-gray-50"> <DashboardGrid
<h3 class="text-md font-medium mb-4">Database Mappings</h3> {dashboards}
{#if fetchingDbs} bind:selectedIds={selectedDashboardIds}
<p>Loading databases and suggestions...</p>
{:else if sourceDatabases.length > 0}
<MappingTable
{sourceDatabases}
{targetDatabases}
{mappings}
{suggestions}
on:update={handleMappingUpdate}
/> />
{:else if sourceEnvId && targetEnvId} {:else}
<button <p class="text-gray-500 italic">Select a source environment to view dashboards.</p>
on:click={fetchDatabases}
class="text-indigo-600 hover:text-indigo-500 text-sm font-medium"
>
Refresh Databases & Suggestions
</button>
{/if} {/if}
</div> </div>
{/if} <!-- [/DEF:DashboardSelectionSection] -->
<button
on:click={startMigration} <div class="flex items-center mb-4">
disabled={!sourceEnvId || !targetEnvId || sourceEnvId === targetEnvId || selectedDashboardIds.length === 0} <input
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-gray-400" id="replace-db"
> type="checkbox"
Start Migration bind:checked={replaceDb}
</button> on:change={() => { if (replaceDb && sourceDatabases.length === 0) fetchDatabases(); }}
class="h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded"
/>
<label for="replace-db" class="ml-2 block text-sm text-gray-900">
Replace Database (Apply Mappings)
</label>
</div>
{#if replaceDb}
<div class="mb-8 p-4 border rounded-md bg-gray-50">
<h3 class="text-md font-medium mb-4">Database Mappings</h3>
{#if fetchingDbs}
<p>Loading databases and suggestions...</p>
{:else if sourceDatabases.length > 0}
<MappingTable
{sourceDatabases}
{targetDatabases}
{mappings}
{suggestions}
on:update={handleMappingUpdate}
/>
{:else if sourceEnvId && targetEnvId}
<button
on:click={fetchDatabases}
class="text-indigo-600 hover:text-indigo-500 text-sm font-medium"
>
Refresh Databases & Suggestions
</button>
{/if}
</div>
{/if}
<button
on:click={startMigration}
disabled={!sourceEnvId || !targetEnvId || sourceEnvId === targetEnvId || selectedDashboardIds.length === 0}
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:bg-gray-400"
>
Start Migration
</button>
{/if}
</div> </div>
<!-- Modals -->
<TaskLogViewer
bind:show={showLogViewer}
taskId={logViewerTaskId}
taskStatus={logViewerTaskStatus}
on:close={() => showLogViewer = false}
/>
<PasswordPrompt
bind:show={showPasswordPrompt}
databases={passwordPromptDatabases}
errorMessage={passwordPromptErrorMessage}
on:resume={handleResumeMigration}
on:cancel={() => showPasswordPrompt = false}
/>
<!-- [/SECTION] --> <!-- [/SECTION] -->
<style> <style>

View File

@@ -0,0 +1,120 @@
/**
* Service for interacting with the Task Management API.
*/
const API_BASE = '/api/tasks';
/**
* Fetch a list of tasks with pagination and optional status filter.
* @param {number} limit - Maximum number of tasks to return.
* @param {number} offset - Number of tasks to skip.
* @param {string|null} status - Filter by task status (optional).
* @returns {Promise<Array>} List of tasks.
*/
export async function getTasks(limit = 10, offset = 0, status = null) {
const params = new URLSearchParams({
limit: limit.toString(),
offset: offset.toString()
});
if (status) {
params.append('status', status);
}
const response = await fetch(`${API_BASE}?${params.toString()}`);
if (!response.ok) {
throw new Error(`Failed to fetch tasks: ${response.statusText}`);
}
return await response.json();
}
/**
* Fetch details for a specific task.
* @param {string} taskId - The ID of the task.
* @returns {Promise<Object>} Task details.
*/
export async function getTask(taskId) {
const response = await fetch(`${API_BASE}/${taskId}`);
if (!response.ok) {
throw new Error(`Failed to fetch task ${taskId}: ${response.statusText}`);
}
return await response.json();
}
/**
* Fetch logs for a specific task.
* @param {string} taskId - The ID of the task.
* @returns {Promise<Array>} List of log entries.
*/
export async function getTaskLogs(taskId) {
// Currently, logs are included in the task object, but we might have a separate endpoint later.
// For now, we fetch the task and return its logs.
// Or if we implement T017 (GET /api/tasks/{task_id}/logs), we would use that.
// The current backend implementation in tasks.py does NOT have a separate /logs endpoint yet.
// T017 is in Phase 3.
// So for now, we'll fetch the task.
const task = await getTask(taskId);
return task.logs || [];
}
/**
* Resume a task that is awaiting input (e.g., passwords).
* @param {string} taskId - The ID of the task.
* @param {Object} passwords - Map of database names to passwords.
* @returns {Promise<Object>} Updated task object.
*/
export async function resumeTask(taskId, passwords) {
const response = await fetch(`${API_BASE}/${taskId}/resume`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ passwords })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to resume task: ${response.statusText}`);
}
return await response.json();
}
/**
* Resolve a task that is awaiting mapping.
* @param {string} taskId - The ID of the task.
* @param {Object} resolutionParams - Resolution parameters.
* @returns {Promise<Object>} Updated task object.
*/
export async function resolveTask(taskId, resolutionParams) {
const response = await fetch(`${API_BASE}/${taskId}/resolve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ resolution_params: resolutionParams })
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `Failed to resolve task: ${response.statusText}`);
}
return await response.json();
}
/**
* Clear tasks based on status.
* @param {string|null} status - Filter by task status (optional).
*/
export async function clearTasks(status = null) {
const params = new URLSearchParams();
if (status) {
params.append('status', status);
}
const response = await fetch(`${API_BASE}?${params.toString()}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error(`Failed to clear tasks: ${response.statusText}`);
}
}

View File

@@ -9,4 +9,5 @@ export interface DashboardSelection {
selected_ids: number[]; selected_ids: number[];
source_env_id: string; source_env_id: string;
target_env_id: string; target_env_id: string;
replace_db_config?: boolean;
} }

View File

@@ -1,6 +1,6 @@
# API Contracts: Migration UI Improvements # API Contracts: Migration UI Improvements
**Date**: 2025-12-27 | **Status**: Draft **Date**: 2025-12-27 | **Status**: Implemented
## Overview ## Overview
@@ -18,7 +18,7 @@ All endpoints require authentication using the existing session mechanism.
### 1. List Migration Tasks ### 1. List Migration Tasks
**Endpoint**: `GET /tasks` **Endpoint**: `GET /api/tasks`
**Purpose**: Retrieve a paginated list of migration tasks **Purpose**: Retrieve a paginated list of migration tasks
@@ -26,7 +26,7 @@ All endpoints require authentication using the existing session mechanism.
``` ```
limit: integer (query, optional) - Number of tasks to return (default: 10, max: 50) limit: integer (query, optional) - Number of tasks to return (default: 10, max: 50)
offset: integer (query, optional) - Pagination offset (default: 0) offset: integer (query, optional) - Pagination offset (default: 0)
status: string (query, optional) - Filter by task status (PENDING, RUNNING, SUCCESS, FAILED, AWAITING_INPUT) status: string (query, optional) - Filter by task status (PENDING, RUNNING, SUCCESS, FAILED, AWAITING_INPUT, AWAITING_MAPPING)
``` ```
**Response**: `200 OK` **Response**: `200 OK`

View File

@@ -55,20 +55,20 @@ This document provides actionable, dependency-ordered tasks for implementing the
## Phase 1: Setup (Project Initialization) ## Phase 1: Setup (Project Initialization)
- [ ] T001 Verify project structure and create missing directories - [x] T001 Verify project structure and create missing directories
- Check backend/src/api/routes/ exists - Check backend/src/api/routes/ exists
- Check backend/src/models/ exists - Check backend/src/models/ exists
- Check frontend/src/components/ exists - Check frontend/src/components/ exists
- Check frontend/src/services/ exists - Check frontend/src/services/ exists
- Create any missing directories per plan.md structure - Create any missing directories per plan.md structure
- [ ] T002 Verify Python 3.9+ and Node.js 18+ dependencies - [x] T002 Verify Python 3.9+ and Node.js 18+ dependencies
- Check Python version >= 3.9 - Check Python version >= 3.9
- Check Node.js version >= 18 - Check Node.js version >= 18
- Verify FastAPI, Pydantic, SQLAlchemy installed - Verify FastAPI, Pydantic, SQLAlchemy installed
- Verify SvelteKit, Tailwind CSS configured - Verify SvelteKit, Tailwind CSS configured
- [ ] T003 Initialize task tracking for this implementation - [x] T003 Initialize task tracking for this implementation
- Create implementation log in backend/logs/implementation.log - Create implementation log in backend/logs/implementation.log
- Document start time and initial state - Document start time and initial state
@@ -78,35 +78,35 @@ This document provides actionable, dependency-ordered tasks for implementing the
### Backend Core Extensions ### Backend Core Extensions
- [ ] T004 [P] Extend TaskStatus enum in backend/src/core/task_manager.py - [x] T004 [P] Extend TaskStatus enum in backend/src/core/task_manager/models.py
- Add new state: `AWAITING_INPUT` - Add new state: `AWAITING_INPUT`
- Update state transition logic - Update state transition logic
- Add validation for new state - Add validation for new state
- [ ] T005 [P] Extend Task class in backend/src/core/task_manager.py - [x] T005 [P] Extend Task class in backend/src/core/task_manager/models.py
- Add `input_required: bool` field - Add `input_required: bool` field
- Add `input_request: Dict | None` field - Add `input_request: Dict | None` field
- Add `logs: List[LogEntry]` field - Add `logs: List[LogEntry]` field
- Update constructor and validation - Update constructor and validation
- [ ] T006 [P] Implement task history retrieval in TaskManager - [x] T006 [P] Implement task history retrieval in TaskManager (backend/src/core/task_manager/manager.py)
- Add `get_tasks(limit, offset, status)` method - Add `get_tasks(limit, offset, status)` method
- Add `get_task_logs(task_id)` method - Add `get_task_logs(task_id)` method
- Add `persist_awaiting_input_tasks()` method - Add `persist_awaiting_input_tasks()` method
- Add `load_persisted_tasks()` method - Add `load_persisted_tasks()` method
- [ ] T007 [P] Create SQLite schema for persistent tasks - [x] T007 [P] Verify/Update SQLite schema in backend/src/core/task_manager/persistence.py
- Create migration script for `persistent_tasks` table - Ensure `persistent_tasks` table exists with required fields
- Add indexes for status and created_at - Add indexes for status and created_at if missing
- Test schema creation - Verify schema creation in `_ensure_db_exists`
- [ ] T008 [P] Extend MigrationPlugin error handling - [x] T008 [P] Extend MigrationPlugin error handling
- Add pattern matching for Superset password errors - Add pattern matching for Superset password errors
- Detect "Must provide a password for the database" message - Detect "Must provide a password for the database" message
- Extract database name from error context - Extract database name from error context
- Transition task to AWAITING_INPUT state - Transition task to AWAITING_INPUT state
- [ ] T009 [P] Implement password injection mechanism - [x] T009 [P] Implement password injection mechanism
- Add method to resume task with credentials - Add method to resume task with credentials
- Validate password format - Validate password format
- Handle multiple database passwords - Handle multiple database passwords
@@ -114,19 +114,19 @@ This document provides actionable, dependency-ordered tasks for implementing the
### Backend API Routes ### Backend API Routes
- [ ] T010 [P] Create backend/src/api/routes/tasks.py - [x] T010 [P] Create backend/src/api/routes/tasks.py
- Create file with basic route definitions - Create file with basic route definitions
- Add route handlers with stubbed responses - Add route handlers with stubbed responses
- Register router in __init__.py (covered by T011) - Register router in __init__.py (covered by T011)
- Add basic error handling structure - Add basic error handling structure
- [ ] T011 [P] Add task routes to backend/src/api/routes/__init__.py - [x] T011 [P] Add task routes to backend/src/api/routes/__init__.py
- Import and register tasks router - Import and register tasks router
- Verify route registration - Verify route registration
### Frontend Services ### Frontend Services
- [ ] T012 [P] Create frontend/src/services/taskService.js - [x] T012 [P] Create frontend/src/services/taskService.js
- Implement `getTasks(limit, offset, status)` function - Implement `getTasks(limit, offset, status)` function
- Implement `getTaskLogs(taskId)` function - Implement `getTaskLogs(taskId)` function
- Implement `resumeTask(taskId, passwords)` function - Implement `resumeTask(taskId, passwords)` function
@@ -134,19 +134,19 @@ This document provides actionable, dependency-ordered tasks for implementing the
### Frontend Components ### Frontend Components
- [ ] T013 [P] Create frontend/src/components/TaskHistory.svelte - [x] T013 [P] Create frontend/src/components/TaskHistory.svelte
- Display task list with ID, status, start time - Display task list with ID, status, start time
- Add "View Logs" action button - Add "View Logs" action button
- Handle empty state - Handle empty state
- Support real-time updates - Support real-time updates
- [ ] T014 [P] Create frontend/src/components/TaskLogViewer.svelte - [x] T014 [P] Create frontend/src/components/TaskLogViewer.svelte
- Display modal with log entries - Display modal with log entries
- Show timestamp, level, message, context - Show timestamp, level, message, context
- Auto-scroll to latest logs - Auto-scroll to latest logs
- Close button functionality - Close button functionality
- [ ] T015 [P] Create frontend/src/components/PasswordPrompt.svelte - [x] T015 [P] Create frontend/src/components/PasswordPrompt.svelte
- Display database name and error message - Display database name and error message
- Show password input fields (dynamic for multiple databases) - Show password input fields (dynamic for multiple databases)
- Submit button with validation - Submit button with validation
@@ -162,50 +162,50 @@ This document provides actionable, dependency-ordered tasks for implementing the
### Backend Implementation ### Backend Implementation
- [ ] T016 [US1] Implement GET /api/tasks endpoint logic - [x] T016 [US1] Implement GET /api/tasks endpoint logic
- Query TaskManager for task list - Query TaskManager for task list
- Apply limit/offset pagination - Apply limit/offset pagination
- Apply status filter if provided - Apply status filter if provided
- Return TaskListResponse format - Return TaskListResponse format
- [ ] T017 [US1] Implement GET /api/tasks/{task_id}/logs endpoint logic - [x] T017 [US1] Implement GET /api/tasks/{task_id}/logs endpoint logic
- Validate task_id exists - Validate task_id exists
- Retrieve logs from TaskManager - Retrieve logs from TaskManager
- Return TaskLogResponse format - Return TaskLogResponse format
- Handle not found errors - Handle not found errors
- [ ] T018 [US1] Add task status update WebSocket support - [x] T018 [US1] Add task status update WebSocket support
- Extend existing WebSocket infrastructure - Extend existing WebSocket infrastructure
- Broadcast status changes to /ws/tasks/{task_id}/status - Broadcast status changes to /ws/tasks/{task_id}/status
- Broadcast log updates to /ws/tasks/{task_id}/logs - Broadcast log updates to /ws/tasks/{task_id}/logs
### Frontend Implementation ### Frontend Implementation
- [ ] T019 [US1] Integrate TaskHistory component into migration page - [x] T019 [US1] Integrate TaskHistory component into migration page
- Add component to frontend/src/routes/migration/+page.svelte - Add component to frontend/src/routes/migration/+page.svelte
- Fetch tasks on page load - Fetch tasks on page load
- Handle loading state - Handle loading state
- Display error messages - Display error messages
- [ ] T020 [US1] Implement real-time status updates - [x] T020 [US1] Implement real-time status updates
- Subscribe to WebSocket channel for task updates - Subscribe to WebSocket channel for task updates
- Update task list on status change - Update task list on status change
- Add visual indicators for running tasks - Add visual indicators for running tasks
- [ ] T021 [US1] Add task list pagination - [x] T021 [US1] Add task list pagination
- Implement "Load More" button - Implement "Load More" button
- Handle offset updates - Handle offset updates
- Maintain current task list while loading more - Maintain current task list while loading more
### Testing ### Testing
- [ ] T022 [US1] Test task list retrieval - [x] T022 [US1] Test task list retrieval
- Create test migration tasks - Create test migration tasks
- Verify API returns correct format - Verify API returns correct format
- Verify pagination works - Verify pagination works
- Verify status filtering works - Verify status filtering works
- [ ] T023 [US1] Test real-time updates - [x] T023 [US1] Test real-time updates
- Start a migration - Start a migration
- Verify task appears in list - Verify task appears in list
- Verify status updates in real-time - Verify status updates in real-time
@@ -221,39 +221,39 @@ This document provides actionable, dependency-ordered tasks for implementing the
### Backend Implementation ### Backend Implementation
- [ ] T024 [P] [US2] Enhance log storage in TaskManager - [x] T024 [P] [US2] Enhance log storage in TaskManager
- Ensure logs are retained for all task states - Ensure logs are retained for all task states
- Add log context preservation - Add log context preservation
- Implement log cleanup on task retention - Implement log cleanup on task retention
### Frontend Implementation ### Frontend Implementation
- [ ] T025 [US2] Implement TaskLogViewer modal integration - [x] T025 [US2] Implement TaskLogViewer modal integration
- Add "View Logs" button to TaskHistory component - Add "View Logs" button to TaskHistory component
- Wire button to open TaskLogViewer modal - Wire button to open TaskLogViewer modal
- Pass task_id to modal - Pass task_id to modal
- Fetch logs when modal opens - Fetch logs when modal opens
- [ ] T026 [US2] Implement log display formatting - [x] T026 [US2] Implement log display formatting
- Color-code by log level (INFO=blue, WARNING=yellow, ERROR=red) - Color-code by log level (INFO=blue, WARNING=yellow, ERROR=red)
- Format timestamps nicely - Format timestamps nicely
- Display context as JSON or formatted text - Display context as JSON or formatted text
- Auto-scroll to bottom on new logs - Auto-scroll to bottom on new logs
- [ ] T027 [US2] Add log refresh functionality - [x] T027 [US2] Add log refresh functionality
- Add refresh button in modal - Add refresh button in modal
- Poll for new logs every 5 seconds while modal open - Poll for new logs every 5 seconds while modal open
- Show "new logs available" indicator - Show "new logs available" indicator
### Testing ### Testing
- [ ] T028 [US2] Test log retrieval - [x] T028 [US2] Test log retrieval
- Create task with various log entries - Create task with various log entries
- Verify logs are returned correctly - Verify logs are returned correctly
- Verify log context is preserved - Verify log context is preserved
- Test with large log files - Test with large log files
- [ ] T029 [US2] Test log viewer UI - [x] T029 [US2] Test log viewer UI
- Open logs for completed task - Open logs for completed task
- Open logs for running task - Open logs for running task
- Verify formatting and readability - Verify formatting and readability
@@ -269,19 +269,19 @@ This document provides actionable, dependency-ordered tasks for implementing the
### Backend Implementation ### Backend Implementation
- [ ] T030 [US3] Implement password error detection in MigrationPlugin - [x] T030 [US3] Implement password error detection in MigrationPlugin
- Add error pattern matching for Superset 422 errors - Add error pattern matching for Superset 422 errors
- Extract database name from error message - Extract database name from error message
- Create DatabasePasswordRequest object - Create DatabasePasswordRequest object
- Transition task to AWAITING_INPUT state - Transition task to AWAITING_INPUT state
- [ ] T031 [US3] Implement task resumption with passwords - [x] T031 [US3] Implement task resumption with passwords
- Add validation for password format - Add validation for password format
- Inject passwords into migration context - Inject passwords into migration context
- Resume task execution from failure point - Resume task execution from failure point
- Handle multiple database passwords - Handle multiple database passwords
- [ ] T032 [US3] Add task persistence for AWAITING_INPUT state - [x] T032 [US3] Add task persistence for AWAITING_INPUT state
- Persist task context and input_request - Persist task context and input_request
- Load persisted tasks on backend restart - Load persisted tasks on backend restart
- Clear persisted data on task completion - Clear persisted data on task completion
@@ -289,44 +289,44 @@ This document provides actionable, dependency-ordered tasks for implementing the
### Frontend Implementation ### Frontend Implementation
- [ ] T033 [US3] Implement password prompt detection - [x] T033 [US3] Implement password prompt detection
- Monitor task status changes - Monitor task status changes
- Detect AWAITING_INPUT state - Detect AWAITING_INPUT state
- Show notification to user - Show notification to user
- Update task list to show "Requires Input" indicator - Update task list to show "Requires Input" indicator
- [ ] T034 [US3] Wire PasswordPrompt to task resumption - [x] T034 [US3] Wire PasswordPrompt to task resumption
- Connect form submission to taskService.resumeTask() - Connect form submission to taskService.resumeTask()
- Handle success (close prompt, resume task) - Handle success (close prompt, resume task)
- Handle failure (show error, keep prompt open) - Handle failure (show error, keep prompt open)
- Support multiple database inputs - Support multiple database inputs
- [ ] T035 [US3] Add visual indicators for password-required tasks - [x] T035 [US3] Add visual indicators for password-required tasks
- Highlight tasks needing input in task list - Highlight tasks needing input in task list
- Add badge or icon - Add badge or icon
- Show count of pending inputs - Show count of pending inputs
### Testing ### Testing
- [ ] T036 [US3] Test password error detection - [x] T036 [US3] Test password error detection
- Mock Superset password error - Mock Superset password error
- Verify error is detected - Verify error is detected
- Verify task transitions to AWAITING_INPUT - Verify task transitions to AWAITING_INPUT
- Verify DatabasePasswordRequest is created - Verify DatabasePasswordRequest is created
- [ ] T037 [US3] Test password resumption - [x] T037 [US3] Test password resumption
- Provide correct password - Provide correct password
- Verify task resumes - Verify task resumes
- Verify task completes successfully - Verify task completes successfully
- Test with incorrect password (should prompt again) - Test with incorrect password (should prompt again)
- [ ] T038 [US3] Test persistence across restarts - [x] T038 [US3] Test persistence across restarts
- Create AWAITING_INPUT task - Create AWAITING_INPUT task
- Restart backend - Restart backend
- Verify task is loaded - Verify task is loaded
- Verify password prompt still works - Verify password prompt still works
- [ ] T039 [US3] Test multiple database passwords - [x] T039 [US3] Test multiple database passwords
- Create migration requiring 2+ databases - Create migration requiring 2+ databases
- Verify all databases listed in prompt - Verify all databases listed in prompt
- Verify all passwords submitted - Verify all passwords submitted
@@ -338,13 +338,13 @@ This document provides actionable, dependency-ordered tasks for implementing the
### Integration & E2E ### Integration & E2E
- [ ] T040 [P] Integrate all components on migration page - [x] T040 [P] Integrate all components on migration page
- Add TaskHistory to migration page - Add TaskHistory to migration page
- Add password prompt handling - Add password prompt handling
- Ensure WebSocket connections work - Ensure WebSocket connections work
- Test complete user flow - Test complete user flow
- [ ] T041 [P] Add loading states and error boundaries - [x] T041 [P] Add loading states and error boundaries
- Show loading spinners during API calls - Show loading spinners during API calls
- Handle API errors gracefully - Handle API errors gracefully
- Show user-friendly error messages - Show user-friendly error messages
@@ -352,13 +352,14 @@ This document provides actionable, dependency-ordered tasks for implementing the
### Configuration & Security ### Configuration & Security
- [ ] T042 [P] Add configuration options - [x] T042 [P] Add configuration options to backend/src/core/config_models.py
- Task retention days (default: 30) - Extend `GlobalSettings` with `task_retention` fields:
- Task retention limit (default: 100) - `retention_days` (default: 30)
- Pagination limits (default: 10, max: 50) - `retention_limit` (default: 100)
- Password complexity requirements - Add `pagination_limit` to settings (default: 10)
- Update `ConfigManager` to handle new fields
- [ ] T043 [P] Security review - [x] T043 [P] Security review
- Verify passwords are not logged - Verify passwords are not logged
- Verify passwords are not stored permanently - Verify passwords are not stored permanently
- Verify input validation on all endpoints - Verify input validation on all endpoints
@@ -366,12 +367,12 @@ This document provides actionable, dependency-ordered tasks for implementing the
### Documentation ### Documentation
- [ ] T044 [P] Update API documentation - [x] T044 [P] Update API documentation
- Add new endpoints to OpenAPI spec - Add new endpoints to OpenAPI spec
- Update API contract examples - Update API contract examples
- Document WebSocket channels - Document WebSocket channels
- [ ] T045 [P] Update quickstart guide - [x] T045 [P] Update quickstart guide
- Add task history section - Add task history section
- Add log viewing section - Add log viewing section
- Add password prompt section - Add password prompt section
@@ -379,31 +380,31 @@ This document provides actionable, dependency-ordered tasks for implementing the
### Testing & Quality ### Testing & Quality
- [ ] T046 [P] Write unit tests for backend - [x] T046 [P] Write unit tests for backend
- Test TaskManager extensions - Test TaskManager extensions
- Test MigrationPlugin error detection - Test MigrationPlugin error detection
- Test API endpoints - Test API endpoints
- Test password validation - Test password validation
- [ ] T047 [P] Write unit tests for frontend - [x] T047 [P] Write unit tests for frontend
- Test taskService functions - Test taskService functions
- Test TaskHistory component - Test TaskHistory component
- Test TaskLogViewer component - Test TaskLogViewer component
- Test PasswordPrompt component - Test PasswordPrompt component
- [ ] T048 [P] Write integration tests - [x] T048 [P] Write integration tests
- Test complete password flow - Test complete password flow
- Test task persistence - Test task persistence
- Test WebSocket updates - Test WebSocket updates
- Test error recovery - Test error recovery
- [ ] T049 [P] Run full test suite - [x] T049 [P] Run full test suite
- Execute pytest for backend - Execute pytest for backend
- Execute frontend tests - Execute frontend tests
- Fix any failing tests - Fix any failing tests
- Verify all acceptance criteria met - Verify all acceptance criteria met
- [ ] T050 [P] Final validation - [x] T050 [P] Final validation
- Verify all user stories work independently - Verify all user stories work independently
- Verify all acceptance scenarios pass - Verify all acceptance scenarios pass
- Check performance (pagination, real-time updates) - Check performance (pagination, real-time updates)