From a747a163c8dd1fb353fb48da82527f237756dc5e Mon Sep 17 00:00:00 2001 From: busya Date: Tue, 30 Dec 2025 22:02:51 +0300 Subject: [PATCH] backup worked --- backend/mappings.db | Bin 28672 -> 36864 bytes backend/requirements.txt | 57 +++-- backend/src/api/routes/environments.py | 54 ++++- backend/src/app.py | 19 +- backend/src/core/config_models.py | 8 + backend/src/core/database.py | 27 +++ backend/src/core/scheduler.py | 99 ++++++++ backend/src/core/task_manager/cleanup.py | 38 +++ backend/src/core/task_manager/manager.py | 21 +- backend/src/core/task_manager/persistence.py | 229 +++++++++---------- backend/src/dependencies.py | 12 + backend/src/models/task.py | 34 +++ backend/src/plugins/backup.py | 17 +- backend/tasks.db | Bin 0 -> 36864 bytes frontend/package-lock.json | 13 ++ frontend/package.json | 3 + frontend/src/components/Navbar.svelte | 6 + frontend/src/components/TaskList.svelte | 94 ++++++++ frontend/src/lib/api.js | 12 +- frontend/src/pages/Settings.svelte | 30 ++- frontend/src/routes/tasks/+page.svelte | 140 ++++++++++++ specs/009-backup-scheduler/tasks.md | 46 ++-- 22 files changed, 768 insertions(+), 191 deletions(-) create mode 100644 backend/src/core/scheduler.py create mode 100644 backend/src/core/task_manager/cleanup.py create mode 100644 backend/src/models/task.py create mode 100644 backend/tasks.db create mode 100644 frontend/src/components/TaskList.svelte create mode 100644 frontend/src/routes/tasks/+page.svelte diff --git a/backend/mappings.db b/backend/mappings.db index 99c019c64029cebd529ee9e08d527a3b6e016055..f255c8d6b573e1b809ad5c4733df06b147e27f48 100644 GIT binary patch delta 337 zcmZp8z}T>WX@ayM7Xt$WClJE`%S0VxSuO^>vQA$99}FCv@eF)_`S(r zp6kiR#>X5E%}ng<;;O2Qt?ng>Nja${iN)FRMXAa8MJdI|Y!2rjSH}=ng%C$4A6Eq= znaQ^NGAt#P1*wzkdBs>u5=%;pfh^JF)V#9HqWrwv)Vz}T%oK$%#~^19#~>XAE>57i zMJ1^z@rfl0EIE{WxWk0n26 settings.task_retention_limit: + to_delete = [t.id for t in tasks[settings.task_retention_limit:]] + self.persistence_service.delete_tasks(to_delete) + logger.info(f"Deleted {len(to_delete)} tasks exceeding limit of {settings.task_retention_limit}") + +# [/DEF:TaskCleanupService] \ No newline at end of file diff --git a/backend/src/core/task_manager/manager.py b/backend/src/core/task_manager/manager.py index b0a9c69..839e07a 100644 --- a/backend/src/core/task_manager/manager.py +++ b/backend/src/core/task_manager/manager.py @@ -71,6 +71,7 @@ class TaskManager: task = Task(plugin_id=plugin_id, params=params, user_id=user_id) self.tasks[task.id] = task + self.persistence_service.persist_task(task) logger.info(f"Task {task.id} created and scheduled for execution") self.loop.create_task(self._run_task(task.id)) # Schedule task for execution return task @@ -89,6 +90,7 @@ class TaskManager: logger.info(f"Starting execution of task {task_id} for plugin '{plugin.name}'") task.status = TaskStatus.RUNNING task.started_at = datetime.utcnow() + self.persistence_service.persist_task(task) self._add_log(task_id, "INFO", f"Task started for plugin '{plugin.name}'") try: @@ -113,6 +115,7 @@ class TaskManager: self._add_log(task_id, "ERROR", f"Task failed: {e}", {"error_type": type(e).__name__}) finally: task.finished_at = datetime.utcnow() + self.persistence_service.persist_task(task) logger.info(f"Task {task_id} execution finished with status: {task.status}") # [/DEF:TaskManager._run_task:Function] @@ -132,6 +135,7 @@ class TaskManager: # Update task params with resolution task.params.update(resolution_params) task.status = TaskStatus.RUNNING + self.persistence_service.persist_task(task) self._add_log(task_id, "INFO", "Task resumed after mapping resolution.") # Signal the future to continue @@ -150,6 +154,7 @@ class TaskManager: if not task: return task.status = TaskStatus.AWAITING_MAPPING + self.persistence_service.persist_task(task) self.task_futures[task_id] = self.loop.create_future() try: @@ -235,6 +240,7 @@ class TaskManager: log_entry = LogEntry(level=level, message=message, context=context) task.logs.append(log_entry) + self.persistence_service.persist_task(task) # Notify subscribers if task_id in self.subscribers: @@ -266,16 +272,10 @@ class TaskManager: del self.subscribers[task_id] # [/DEF:TaskManager.unsubscribe_logs:Function] - # [DEF:TaskManager.persist_awaiting_input_tasks:Function] - # @PURPOSE: Persist tasks in AWAITING_INPUT state using persistence service. - def persist_awaiting_input_tasks(self) -> None: - self.persistence_service.persist_tasks(list(self.tasks.values())) - # [/DEF:TaskManager.persist_awaiting_input_tasks:Function] - # [DEF:TaskManager.load_persisted_tasks:Function] # @PURPOSE: Load persisted tasks using persistence service. def load_persisted_tasks(self) -> None: - loaded_tasks = self.persistence_service.load_tasks() + loaded_tasks = self.persistence_service.load_tasks(limit=100) for task in loaded_tasks: if task.id not in self.tasks: self.tasks[task.id] = task @@ -299,9 +299,8 @@ class TaskManager: task.status = TaskStatus.AWAITING_INPUT task.input_required = True task.input_request = input_request + self.persistence_service.persist_task(task) self._add_log(task_id, "INFO", "Task paused for user input", {"input_request": input_request}) - - self.persist_awaiting_input_tasks() # [/DEF:TaskManager.await_input:Function] # [DEF:TaskManager.resume_task_with_password:Function] @@ -326,13 +325,11 @@ class TaskManager: task.input_required = False task.input_request = None task.status = TaskStatus.RUNNING + self.persistence_service.persist_task(task) self._add_log(task_id, "INFO", "Task resumed with passwords", {"databases": list(passwords.keys())}) if task_id in self.task_futures: self.task_futures[task_id].set_result(True) - - # 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.clear_tasks:Function] diff --git a/backend/src/core/task_manager/persistence.py b/backend/src/core/task_manager/persistence.py index 38d3abe..8abbf21 100644 --- a/backend/src/core/task_manager/persistence.py +++ b/backend/src/core/task_manager/persistence.py @@ -1,141 +1,122 @@ # [DEF:TaskPersistenceModule:Module] -# @SEMANTICS: persistence, sqlite, task, storage -# @PURPOSE: Handles the persistence of tasks, specifically those awaiting user input, to a SQLite database. +# @SEMANTICS: persistence, sqlite, sqlalchemy, task, storage +# @PURPOSE: Handles the persistence of tasks using SQLAlchemy and the tasks.db database. # @LAYER: Core # @RELATION: Used by TaskManager to save and load tasks. -# @INVARIANT: Database schema must match the Task model structure. -# @CONSTRAINT: Uses synchronous SQLite operations (blocking), should be used carefully. +# @INVARIANT: Database schema must match the TaskRecord model structure. # [SECTION: IMPORTS] -import sqlite3 -import json from datetime import datetime -from pathlib import Path -from typing import Dict, List, Optional, Any +from typing import List, Optional, Dict, Any +import json -from .models import Task, TaskStatus +from sqlalchemy.orm import Session +from backend.src.models.task import TaskRecord +from backend.src.core.database import TasksSessionLocal +from .models import Task, TaskStatus, LogEntry from ..logger import logger, belief_scope # [/SECTION] # [DEF:TaskPersistenceService:Class] -# @SEMANTICS: persistence, service, database -# @PURPOSE: Provides methods to save and load tasks from a local SQLite database. +# @SEMANTICS: persistence, service, database, sqlalchemy +# @PURPOSE: Provides methods to save and load tasks from the tasks.db database using SQLAlchemy. class TaskPersistenceService: - def __init__(self, db_path: Optional[Path] = None): - if db_path is None: - self.db_path = Path(__file__).parent.parent.parent.parent / "migrations.db" - else: - self.db_path = db_path - self._ensure_db_exists() + def __init__(self): + # We use TasksSessionLocal from database.py + pass - # [DEF:TaskPersistenceService._ensure_db_exists:Function] - # @PURPOSE: Ensures the database directory and table exist. - # @PRE: None. - # @POST: Database file and table are created if they didn't exist. - def _ensure_db_exists(self) -> None: - with belief_scope("TaskPersistenceService._ensure_db_exists"): - self.db_path.parent.mkdir(parents=True, exist_ok=True) - - conn = sqlite3.connect(str(self.db_path)) - cursor = conn.cursor() - - cursor.execute(""" - CREATE TABLE IF NOT EXISTS persistent_tasks ( - id TEXT PRIMARY KEY, - plugin_id TEXT NOT NULL, - status TEXT NOT NULL, - created_at TEXT NOT NULL, - updated_at TEXT NOT NULL, - input_request JSON, - context JSON - ) - """) - conn.commit() - conn.close() - # [/DEF:TaskPersistenceService._ensure_db_exists:Function] + # [DEF:TaskPersistenceService.persist_task:Function] + # @PURPOSE: Persists or updates a single task in the database. + # @PARAM: task (Task) - The task object to persist. + def persist_task(self, task: Task) -> None: + with belief_scope("TaskPersistenceService.persist_task", f"task_id={task.id}"): + session: Session = TasksSessionLocal() + try: + record = session.query(TaskRecord).filter(TaskRecord.id == task.id).first() + if not record: + record = TaskRecord(id=task.id) + session.add(record) + + record.type = task.plugin_id + record.status = task.status.value + record.environment_id = task.params.get("environment_id") or task.params.get("source_env_id") + record.started_at = task.started_at + record.finished_at = task.finished_at + record.params = task.params + + # Store logs as JSON, converting datetime to string + record.logs = [] + for log in task.logs: + log_dict = log.dict() + if isinstance(log_dict.get('timestamp'), datetime): + log_dict['timestamp'] = log_dict['timestamp'].isoformat() + record.logs.append(log_dict) + + # Extract error if failed + if task.status == TaskStatus.FAILED: + for log in reversed(task.logs): + if log.level == "ERROR": + record.error = log.message + break + + session.commit() + except Exception as e: + session.rollback() + logger.error(f"Failed to persist task {task.id}: {e}") + finally: + session.close() + # [/DEF:TaskPersistenceService.persist_task:Function] # [DEF:TaskPersistenceService.persist_tasks:Function] - # @PURPOSE: Persists a list of tasks to the database. - # @PRE: Tasks list contains valid Task objects. - # @POST: Tasks matching the criteria (AWAITING_INPUT) are saved/updated in the DB. - # @PARAM: tasks (List[Task]) - The list of tasks to check and persist. + # @PURPOSE: Persists multiple tasks. + # @PARAM: tasks (List[Task]) - The list of tasks to persist. def persist_tasks(self, tasks: List[Task]) -> None: - with belief_scope("TaskPersistenceService.persist_tasks"): - conn = sqlite3.connect(str(self.db_path)) - cursor = conn.cursor() - - count = 0 - for task in tasks: - if task.status == TaskStatus.AWAITING_INPUT: - cursor.execute(""" - INSERT OR REPLACE INTO persistent_tasks - (id, plugin_id, status, created_at, updated_at, input_request, context) - VALUES (?, ?, ?, ?, ?, ?, ?) - """, ( - task.id, - task.plugin_id, - task.status.value, - task.started_at.isoformat() if task.started_at else datetime.utcnow().isoformat(), - datetime.utcnow().isoformat(), - json.dumps(task.input_request) if task.input_request else None, - json.dumps(task.params) - )) - count += 1 - - conn.commit() - conn.close() - logger.info(f"Persisted {count} tasks awaiting input.") + for task in tasks: + self.persist_task(task) # [/DEF:TaskPersistenceService.persist_tasks:Function] # [DEF:TaskPersistenceService.load_tasks:Function] - # @PURPOSE: Loads persisted tasks from the database. - # @PRE: Database exists. - # @POST: Returns a list of Task objects reconstructed from the DB. + # @PURPOSE: Loads tasks from the database. + # @PARAM: limit (int) - Max tasks to load. + # @PARAM: status (Optional[TaskStatus]) - Filter by status. # @RETURN: List[Task] - The loaded tasks. - def load_tasks(self) -> List[Task]: + def load_tasks(self, limit: int = 100, status: Optional[TaskStatus] = None) -> List[Task]: with belief_scope("TaskPersistenceService.load_tasks"): - if not self.db_path.exists(): - return [] - - conn = sqlite3.connect(str(self.db_path)) - cursor = conn.cursor() - - # Check if plugin_id column exists (migration for existing db) - cursor.execute("PRAGMA table_info(persistent_tasks)") - columns = [info[1] for info in cursor.fetchall()] - has_plugin_id = "plugin_id" in columns + session: Session = TasksSessionLocal() + try: + query = session.query(TaskRecord) + if status: + query = query.filter(TaskRecord.status == status.value) + + records = query.order_by(TaskRecord.created_at.desc()).limit(limit).all() + + loaded_tasks = [] + for record in records: + try: + logs = [] + if record.logs: + for log_data in record.logs: + # Handle timestamp conversion if it's a string + if isinstance(log_data.get('timestamp'), str): + log_data['timestamp'] = datetime.fromisoformat(log_data['timestamp']) + logs.append(LogEntry(**log_data)) - if has_plugin_id: - cursor.execute("SELECT id, plugin_id, status, created_at, input_request, context FROM persistent_tasks") - else: - cursor.execute("SELECT id, status, created_at, input_request, context FROM persistent_tasks") - - rows = cursor.fetchall() - - loaded_tasks = [] - for row in rows: - if has_plugin_id: - task_id, plugin_id, status, created_at, input_request_json, context_json = row - else: - task_id, status, created_at, input_request_json, context_json = row - plugin_id = "superset-migration" # Default fallback - - try: - task = Task( - id=task_id, - plugin_id=plugin_id, - status=TaskStatus(status), - started_at=datetime.fromisoformat(created_at), - input_required=True, - input_request=json.loads(input_request_json) if input_request_json else None, - params=json.loads(context_json) if context_json else {} - ) - loaded_tasks.append(task) - except Exception as e: - logger.error(f"Failed to load task {task_id}: {e}") - - conn.close() - return loaded_tasks + task = Task( + id=record.id, + plugin_id=record.type, + status=TaskStatus(record.status), + started_at=record.started_at, + finished_at=record.finished_at, + params=record.params or {}, + logs=logs + ) + loaded_tasks.append(task) + except Exception as e: + logger.error(f"Failed to reconstruct task {record.id}: {e}") + + return loaded_tasks + finally: + session.close() # [/DEF:TaskPersistenceService.load_tasks:Function] # [DEF:TaskPersistenceService.delete_tasks:Function] @@ -145,14 +126,16 @@ class TaskPersistenceService: 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() + session: Session = TasksSessionLocal() + try: + session.query(TaskRecord).filter(TaskRecord.id.in_(task_ids)).delete(synchronize_session=False) + session.commit() + except Exception as e: + session.rollback() + logger.error(f"Failed to delete tasks: {e}") + finally: + session.close() # [/DEF:TaskPersistenceService.delete_tasks:Function] # [/DEF:TaskPersistenceService:Class] - # [/DEF:TaskPersistenceModule:Module] \ No newline at end of file diff --git a/backend/src/dependencies.py b/backend/src/dependencies.py index c60b557..1902865 100755 --- a/backend/src/dependencies.py +++ b/backend/src/dependencies.py @@ -8,6 +8,8 @@ from pathlib import Path from .core.plugin_loader import PluginLoader from .core.task_manager import TaskManager from .core.config_manager import ConfigManager +from .core.scheduler import SchedulerService +from .core.database import init_db # Initialize singletons # Use absolute path relative to this file to ensure plugins are found regardless of CWD @@ -15,6 +17,9 @@ project_root = Path(__file__).parent.parent.parent config_path = project_root / "config.json" config_manager = ConfigManager(config_path=str(config_path)) +# Initialize database before any other services that might use it +init_db() + def get_config_manager() -> ConfigManager: """Dependency injector for the ConfigManager.""" return config_manager @@ -28,6 +33,9 @@ logger.info(f"Available plugins: {[config.name for config in plugin_loader.get_a task_manager = TaskManager(plugin_loader) logger.info("TaskManager initialized") +scheduler_service = SchedulerService(task_manager, config_manager) +logger.info("SchedulerService initialized") + def get_plugin_loader() -> PluginLoader: """Dependency injector for the PluginLoader.""" return plugin_loader @@ -35,4 +43,8 @@ def get_plugin_loader() -> PluginLoader: def get_task_manager() -> TaskManager: """Dependency injector for the TaskManager.""" return task_manager + +def get_scheduler_service() -> SchedulerService: + """Dependency injector for the SchedulerService.""" + return scheduler_service # [/DEF] \ No newline at end of file diff --git a/backend/src/models/task.py b/backend/src/models/task.py new file mode 100644 index 0000000..144e176 --- /dev/null +++ b/backend/src/models/task.py @@ -0,0 +1,34 @@ +# [DEF:backend.src.models.task:Module] +# +# @SEMANTICS: database, task, record, sqlalchemy, sqlite +# @PURPOSE: Defines the database schema for task execution records. +# @LAYER: Domain +# @RELATION: DEPENDS_ON -> sqlalchemy +# +# @INVARIANT: All primary keys are UUID strings. + +# [SECTION: IMPORTS] +from sqlalchemy import Column, String, DateTime, JSON, ForeignKey +from sqlalchemy.sql import func +from .mapping import Base +import uuid +# [/SECTION] + +# [DEF:TaskRecord:Class] +# @PURPOSE: Represents a persistent record of a task execution. +class TaskRecord(Base): + __tablename__ = "task_records" + + id = Column(String, primary_key=True, default=lambda: str(uuid.uuid4())) + type = Column(String, nullable=False) # e.g., "backup", "migration" + status = Column(String, nullable=False) # Enum: "PENDING", "RUNNING", "SUCCESS", "FAILED" + environment_id = Column(String, ForeignKey("environments.id"), nullable=True) + started_at = Column(DateTime(timezone=True), nullable=True) + finished_at = Column(DateTime(timezone=True), nullable=True) + logs = Column(JSON, nullable=True) # Store structured logs as JSON + error = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + params = Column(JSON, nullable=True) +# [/DEF:TaskRecord] + +# [/DEF:backend.src.models.task] \ No newline at end of file diff --git a/backend/src/plugins/backup.py b/backend/src/plugins/backup.py index 60c9fe1..f938d07 100755 --- a/backend/src/plugins/backup.py +++ b/backend/src/plugins/backup.py @@ -71,8 +71,21 @@ class BackupPlugin(PluginBase): } async def execute(self, params: Dict[str, Any]): - env = params["env"] - backup_path = Path(params["backup_path"]) + config_manager = get_config_manager() + env_id = params.get("environment_id") + + # Resolve environment name if environment_id is provided + if env_id: + env_config = next((e for e in config_manager.get_environments() if e.id == env_id), None) + if env_config: + params["env"] = env_config.name + + env = params.get("env") + if not env: + raise KeyError("env") + + backup_path_str = params.get("backup_path") or config_manager.get_config().settings.backup_path + backup_path = Path(backup_path_str) logger = SupersetLogger(log_dir=backup_path / "Logs", console=True) logger.info(f"[BackupPlugin][Entry] Starting backup for {env}.") diff --git a/backend/tasks.db b/backend/tasks.db new file mode 100644 index 0000000000000000000000000000000000000000..dd40c974f50aca44972566e3e34fd4fbc981b56c GIT binary patch literal 36864 zcmeI*+i%-c90zcx+1jRbEj;xB4UQsBYsB378YkfavMwE^UApChG)lwQH%@cVg$-sqjX(D|IrJ6%FgI9aS@cbt|G{h1LEIaj z+tb>%I(?QoTE4urk;|oC?+v5Z30fhIgI?GEy5~g6v&BRNlX){@81)9B%WU51scf`WDxm;n~ZFB)s@_* zi;Eivx$|O}^tRRH`O;A>y|i)jX6j9S7)Out9gUdXq5Xc)ZAGUNE+wLQD*J5*^shvZ8_k>N&4^Pmv(-~c zmtTIC8s42>m51|U`qnXhypWg;(>XIH!nx>!usyz2CPK>JnjXI`2eciXcP}_e&u?uD zvuAOHafKmY;|fB*y_009Whg8-iY&qI_k zMhHLv0uX=z1Rwwb2tWV=5SVuX{Qv*F&j5@c0uX=z1Rwwb2tWV=5P$##=0O0@|K}mf z7$XEA009U<00Izz00bZa0SL^yfO!6&UNxn)-_}0jJ8Teu00bZa0SG_<0uX=z1d;+r zetNa}$%h}8%is1SeUDkDY88y4Y!y{qHe71S#ezp=!z@rrEk>!YMT0&IBNodJb)O9S z_2xETtg${$)igyjWmS`Pg{Z}nqLxf;OVdol(of`QB}LoH>sCImK75glgAU`%A9niL z63I?VHYRNtnp!MmZ;))8J!NfCY`3~|AQE`Zh_;wWYVc(_$!Gygu@pM#w+DRLJ951~ z>NUAdqsLB!?EiJqg4^rH>{-lP=nmTL!y9CFhlX7@i<)AY7wVAP>-71Oq0A%E zz;$_VeSVea|Gs~ZCP2r?FS1js!trJ2gvN1DvWGA03nI|Xvh;zZzf39A*YldgWLi)h z*-#u;E*g#^`-)Esrs}ztQ5@GE1-jO(R(Gp+EXCsM?>Tw7P*8o@C{j&!3Z^2viefmr z@2aY+oamlX(u^%dw=_+Cc(Usv@2sw0jx0b=}m9D)ZIjQ5~-=IU3kFBQb*uB_m%_tu4ziEHj_%{V4B6Hezk&ibYZb zFXHSE)kl0vC6?1n27~7&R?HN+7aA|GI9UwT=9AVBTD-E)7aC-T;`x7i{f)H#!}`yB yhYbP{fB*y_009U<00Izz00bZafwv~GnQo>o@+(?e6uii=4" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 7945d57..1815ba4 100755 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,5 +17,8 @@ "svelte": "^5.43.8", "tailwindcss": "^3.0.0", "vite": "^7.2.4" + }, + "dependencies": { + "date-fns": "^4.1.0" } } diff --git a/frontend/src/components/Navbar.svelte b/frontend/src/components/Navbar.svelte index 2f9e80e..d7b2082 100644 --- a/frontend/src/components/Navbar.svelte +++ b/frontend/src/components/Navbar.svelte @@ -22,6 +22,12 @@ > Migration + + Tasks + + + + + +
+ {#if loading && tasks.length === 0} +
Loading tasks...
+ {:else if tasks.length === 0} +
No tasks found.
+ {:else} +
    + {#each tasks as task (task.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ + \ No newline at end of file diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 98078ea..672b7d3 100755 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -100,19 +100,21 @@ async function requestApi(endpoint, method = 'GET', body = null) { // [DEF:api:Data] // @PURPOSE: API client object with specific methods. export const api = { - getPlugins: () => fetchApi('/plugins/'), - getTasks: () => fetchApi('/tasks/'), + getPlugins: () => fetchApi('/plugins'), + getTasks: () => fetchApi('/tasks'), getTask: (taskId) => fetchApi(`/tasks/${taskId}`), - createTask: (pluginId, params) => postApi('/tasks/', { plugin_id: pluginId, params }), + createTask: (pluginId, params) => postApi('/tasks', { plugin_id: pluginId, params }), // Settings - getSettings: () => fetchApi('/settings/'), + getSettings: () => fetchApi('/settings'), updateGlobalSettings: (settings) => requestApi('/settings/global', 'PATCH', settings), getEnvironments: () => fetchApi('/settings/environments'), addEnvironment: (env) => postApi('/settings/environments', env), updateEnvironment: (id, env) => requestApi(`/settings/environments/${id}`, 'PUT', env), deleteEnvironment: (id) => requestApi(`/settings/environments/${id}`, 'DELETE'), testEnvironmentConnection: (id) => postApi(`/settings/environments/${id}/test`, {}), + updateEnvironmentSchedule: (id, schedule) => requestApi(`/environments/${id}/schedule`, 'PUT', schedule), + getEnvironmentsList: () => fetchApi('/environments'), }; // [/DEF:api_module] @@ -128,3 +130,5 @@ export const addEnvironment = api.addEnvironment; export const updateEnvironment = api.updateEnvironment; export const deleteEnvironment = api.deleteEnvironment; export const testEnvironmentConnection = api.testEnvironmentConnection; +export const updateEnvironmentSchedule = api.updateEnvironmentSchedule; +export const getEnvironmentsList = api.getEnvironmentsList; diff --git a/frontend/src/pages/Settings.svelte b/frontend/src/pages/Settings.svelte index 6b8f73c..87cd202 100755 --- a/frontend/src/pages/Settings.svelte +++ b/frontend/src/pages/Settings.svelte @@ -13,7 +13,7 @@ + +
+
+

Task Management

+ +
+ +
+
+

Recent Tasks

+ +
+ +
+

Task Details & Logs

+ {#if selectedTaskId} +
+ +
+ {:else} +
+

Select a task to view logs and details

+
+ {/if} +
+
+
+ +{#if showBackupModal} +
+
+

Run Manual Backup

+
+ + +
+
+ + +
+
+
+{/if} \ No newline at end of file diff --git a/specs/009-backup-scheduler/tasks.md b/specs/009-backup-scheduler/tasks.md index d8ddc9c..5f0531d 100644 --- a/specs/009-backup-scheduler/tasks.md +++ b/specs/009-backup-scheduler/tasks.md @@ -1,42 +1,42 @@ # Tasks: Backup Scheduler & Unified Task UI ## Phase 1: Setup -- [ ] T001 Initialize SQLite database `tasks.db` and SQLAlchemy engine in `backend/src/core/database.py` -- [ ] T002 Create SQLAlchemy model for `TaskRecord` in `backend/src/models/task.py` -- [ ] T003 Update `backend/src/core/config_models.py` to include `Schedule` and update `Environment` model -- [ ] T004 Create database migrations or initialization script for `tasks.db` +- [x] T001 Initialize SQLite database `tasks.db` and SQLAlchemy engine in `backend/src/core/database.py` +- [x] T002 Create SQLAlchemy model for `TaskRecord` in `backend/src/models/task.py` +- [x] T003 Update `backend/src/core/config_models.py` to include `Schedule` and update `Environment` model +- [x] T004 Create database migrations or initialization script for `tasks.db` ## Phase 2: Foundational -- [ ] T005 [P] Implement `TaskPersistence` layer in `backend/src/core/task_manager/persistence.py` -- [ ] T006 Update `TaskManager` in `backend/src/core/task_manager/manager.py` to use persistence for all jobs -- [ ] T007 Implement `SchedulerService` using `APScheduler` in `backend/src/core/scheduler.py` -- [ ] T008 Integrate `SchedulerService` into main FastAPI application startup in `backend/src/app.py` +- [x] T005 [P] Implement `TaskPersistence` layer in `backend/src/core/task_manager/persistence.py` +- [x] T006 Update `TaskManager` in `backend/src/core/task_manager/manager.py` to use persistence for all jobs +- [x] T007 Implement `SchedulerService` using `APScheduler` in `backend/src/core/scheduler.py` +- [x] T008 Integrate `SchedulerService` into main FastAPI application startup in `backend/src/app.py` ## Phase 3: [US1] Scheduled Backups -- [ ] T009 [US1] Implement schedule loading and registration logic in `SchedulerService` -- [ ] T010 [US1] Update `Environment` settings API to handle `backup_schedule` updates in `backend/src/api/routes/environments.py` -- [ ] T011 [P] [US1] Add schedule configuration fields to Environment edit form in `frontend/src/components/EnvSelector.svelte` (or appropriate component) -- [ ] T012 [US1] Implement validation for Cron expressions in backend and frontend +- [x] T009 [US1] Implement schedule loading and registration logic in `SchedulerService` +- [x] T010 [US1] Update `Environment` settings API to handle `backup_schedule` updates in `backend/src/api/routes/environments.py` +- [x] T011 [P] [US1] Add schedule configuration fields to Environment edit form in `frontend/src/components/EnvSelector.svelte` (or appropriate component) +- [x] T012 [US1] Implement validation for Cron expressions in backend and frontend ## Phase 4: [US2] Unified Task Management UI -- [ ] T013 [US2] Implement `/api/tasks` endpoint to list and filter tasks in `backend/src/api/routes/tasks.py` -- [ ] T014 [US2] Create new Tasks page in `frontend/src/routes/tasks/+page.svelte` -- [ ] T015 [P] [US2] Implement `TaskList` component in `frontend/src/components/TaskList.svelte` -- [ ] T016 [US2] Add "Tasks" link to main navigation in `frontend/src/components/Navbar.svelte` +- [x] T013 [US2] Implement `/api/tasks` endpoint to list and filter tasks in `backend/src/api/routes/tasks.py` +- [x] T014 [US2] Create new Tasks page in `frontend/src/routes/tasks/+page.svelte` +- [x] T015 [P] [US2] Implement `TaskList` component in `frontend/src/components/TaskList.svelte` +- [x] T016 [US2] Add "Tasks" link to main navigation in `frontend/src/components/Navbar.svelte` ## Phase 5: [US3] Manual Backup Trigger -- [ ] T017 [US3] Implement `/api/tasks/backup` POST endpoint in `backend/src/api/routes/tasks.py` -- [ ] T018 [US3] Add "Run Backup" button and environment selection to Tasks page in `frontend/src/routes/tasks/+page.svelte` +- [x] T017 [US3] Implement `/api/tasks/backup` POST endpoint in `backend/src/api/routes/tasks.py` +- [x] T018 [US3] Add "Run Backup" button and environment selection to Tasks page in `frontend/src/routes/tasks/+page.svelte` ## Phase 6: [US4] Task History & Logs -- [ ] T019 [US4] Implement `/api/tasks/{task_id}` GET endpoint for detailed task info and logs in `backend/src/api/routes/tasks.py` -- [ ] T020 [US4] Implement `TaskLogViewer` component in `frontend/src/components/TaskLogViewer.svelte` -- [ ] T021 [US4] Integrate log viewer into TaskList or as a separate modal/page +- [x] T019 [US4] Implement `/api/tasks/{task_id}` GET endpoint for detailed task info and logs in `backend/src/api/routes/tasks.py` +- [x] T020 [US4] Implement `TaskLogViewer` component in `frontend/src/components/TaskLogViewer.svelte` +- [x] T021 [US4] Integrate log viewer into TaskList or as a separate modal/page ## Final Phase: Polish & Cross-cutting concerns -- [ ] T022 Implement task cleanup/retention policy (e.g., delete tasks older than 30 days) +- [x] T022 Implement task cleanup/retention policy (e.g., delete tasks older than 30 days) - [ ] T023 Add real-time updates for task status using WebSockets (optional/refinement) -- [ ] T024 Ensure consistent error handling and logging across scheduler and task manager +- [x] T024 Ensure consistent error handling and logging across scheduler and task manager ## Dependencies - US1 depends on Phase 1 & 2