diff --git a/app/db/scheduled_scripts.py b/app/db/scheduled_scripts.py new file mode 100644 index 0000000..6066cb5 --- /dev/null +++ b/app/db/scheduled_scripts.py @@ -0,0 +1,172 @@ +from typing import Any + +from app.db.database import get_connection +from app.db.migrations import run_migrations + + +def get_scheduled_scripts(): + run_migrations() + con = get_connection() + + rows = con.execute( + """ + SELECT + id, + name, + description, + script_name, + schedule_type, + schedule_time, + timeout_seconds, + COALESCE(is_enabled, 1) AS is_enabled, + COALESCE(is_running, 0) AS is_running, + last_run_at, + last_status, + last_job_id, + last_error_text, + next_run_at, + created_at, + updated_at + FROM scheduled_scripts + ORDER BY name + """ + ).fetchall() + + con.close() + return [dict(row) for row in rows] + + +def get_scheduled_script(script_id: int): + run_migrations() + con = get_connection() + + row = con.execute( + """ + SELECT + id, + name, + description, + script_name, + schedule_type, + schedule_time, + timeout_seconds, + COALESCE(is_enabled, 1) AS is_enabled, + COALESCE(is_running, 0) AS is_running, + last_run_at, + last_status, + last_job_id, + last_error_text, + next_run_at, + created_at, + updated_at + FROM scheduled_scripts + WHERE id = ? + """, + (script_id,), + ).fetchone() + + con.close() + return dict(row) if row else None + + +def create_scheduled_script(metadata: dict[str, Any]) -> int: + run_migrations() + con = get_connection() + + cur = con.execute( + """ + INSERT INTO scheduled_scripts ( + name, + description, + script_name, + schedule_type, + schedule_time, + timeout_seconds, + is_enabled, + is_running, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + """, + ( + metadata.get("name"), + metadata.get("description") or None, + metadata.get("script_name"), + metadata.get("schedule_type"), + metadata.get("schedule_time") or None, + metadata.get("timeout_seconds"), + 1 if metadata.get("is_enabled") else 0, + ), + ) + script_id = cur.lastrowid + con.commit() + con.close() + return script_id + + +def update_scheduled_script(script_id: int, metadata: dict[str, Any]) -> None: + run_migrations() + con = get_connection() + + con.execute( + """ + UPDATE scheduled_scripts + SET name = ?, + description = ?, + schedule_type = ?, + schedule_time = ?, + timeout_seconds = ?, + is_enabled = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + ( + metadata.get("name"), + metadata.get("description") or None, + metadata.get("schedule_type"), + metadata.get("schedule_time") or None, + metadata.get("timeout_seconds"), + 1 if metadata.get("is_enabled") else 0, + script_id, + ), + ) + + con.commit() + con.close() + + +def set_scheduled_script_enabled(script_id: int, is_enabled: bool) -> None: + run_migrations() + con = get_connection() + + con.execute( + """ + UPDATE scheduled_scripts + SET is_enabled = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (1 if is_enabled else 0, script_id), + ) + + con.commit() + con.close() + + +def delete_scheduled_script(script_id: int) -> bool: + run_migrations() + con = get_connection() + + cur = con.execute( + """ + DELETE FROM scheduled_scripts + WHERE id = ? + AND COALESCE(is_running, 0) = 0 + """, + (script_id,), + ) + deleted = cur.rowcount == 1 + con.commit() + con.close() + return deleted diff --git a/app/main.py b/app/main.py index 6d1cacb..0e8eca7 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from .config import read_env_value -from .routes import apps, audit, auth, backups, deployments, health, incidents, jobs, operations, workers +from .routes import apps, audit, auth, backups, deployments, health, incidents, jobs, operations, scheduled_scripts, workers def create_app() -> FastAPI: @@ -29,6 +29,7 @@ def create_app() -> FastAPI: app.include_router(deployments.router) app.include_router(incidents.router) app.include_router(operations.router) + app.include_router(scheduled_scripts.router) app.include_router(jobs.router) app.include_router(workers.router) app.include_router(audit.router) diff --git a/app/routes/jobs.py b/app/routes/jobs.py index a885f3b..61e190c 100644 --- a/app/routes/jobs.py +++ b/app/routes/jobs.py @@ -19,7 +19,7 @@ LIVE_STATUSES = {"queued", "running", "cancelled_requested"} FINISHED_STATUSES = {"success", "failed", "cancelled"} JOB_STATUSES = {"queued", "running", "cancelled_requested", "cancelled", "success", "failed"} JOB_FILTER_STATUSES = ("queued", "running", "success", "failed", "cancelled") -JOB_FILTER_TYPES = ("deploy_app", "deploy_core_service") +JOB_FILTER_TYPES = ("deploy_app", "deploy_core_service", "run_script") IGNORED_RETRY_REPOSITORIES = {"appfactory-tools", "appfactory-infrastructure"} DEFAULT_PAGE_SIZE = 20 diff --git a/app/routes/scheduled_scripts.py b/app/routes/scheduled_scripts.py new file mode 100644 index 0000000..c7e7bfd --- /dev/null +++ b/app/routes/scheduled_scripts.py @@ -0,0 +1,456 @@ +import html + +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.auth import require_user +from app.db.audit import log_audit_event +from app.db.jobs import create_job +from app.db.scheduled_scripts import ( + create_scheduled_script, + delete_scheduled_script, + get_scheduled_script, + get_scheduled_scripts, + set_scheduled_script_enabled, + update_scheduled_script, +) +from app.routes.deployments import render_status_pill +from app.templates.layout import page + +router = APIRouter() +SCHEDULE_TYPES = ("hourly", "daily", "weekly", "monthly") +SCHEDULE_LABELS = { + "hourly": "každou hodinu", + "daily": "denně", + "weekly": "týdně", + "monthly": "měsíčně", +} + + +def clean_optional(value: str | None) -> str: + return (value or "").strip() + + +def render_bool(value) -> str: + return "Ano" if value else "Ne" + + +def render_schedule_type(value: str | None) -> str: + value = value or "" + return SCHEDULE_LABELS.get(value, html.escape(value)) + + +def render_schedule_options(selected: str) -> str: + options = [] + for value in SCHEDULE_TYPES: + selected_attr = " selected" if selected == value else "" + options.append(f'') + return "".join(options) + + +def render_enabled_pill(value) -> str: + if value: + return 'zapnuto' + return 'vypnuto' + + +def render_running_pill(value) -> str: + if value: + return 'běží' + return 'neběží' + + +def validate_script_name(value: str) -> str: + script_name = clean_optional(value) + if not script_name: + raise HTTPException(status_code=400, detail="Název skriptu je povinný") + if not script_name.endswith(".sh"): + raise HTTPException(status_code=400, detail="Název skriptu musí končit .sh") + if "/" in script_name or "\\" in script_name or ".." in script_name: + raise HTTPException(status_code=400, detail="Název skriptu nesmí obsahovat cestu") + return script_name + + +def validate_schedule_type(value: str) -> str: + schedule_type = clean_optional(value) + if schedule_type not in SCHEDULE_TYPES: + raise HTTPException(status_code=400, detail="Neplatná frekvence") + return schedule_type + + +def validate_timeout(value: str | int) -> int: + try: + timeout_seconds = int(value) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="Timeout musí být číslo") + if timeout_seconds < 10 or timeout_seconds > 3600: + raise HTTPException(status_code=400, detail="Timeout musí být mezi 10 a 3600 sekundami") + return timeout_seconds + + +def form_metadata( + name: str, + description: str, + schedule_type: str, + schedule_time: str, + timeout_seconds: str, + is_enabled: str | None, +) -> dict: + name_value = clean_optional(name) + if not name_value: + raise HTTPException(status_code=400, detail="Název je povinný") + return { + "name": name_value, + "description": clean_optional(description), + "schedule_type": validate_schedule_type(schedule_type), + "schedule_time": clean_optional(schedule_time), + "timeout_seconds": validate_timeout(timeout_seconds), + "is_enabled": bool(is_enabled), + } + + +def render_form(script: dict | None, action: str, include_script_name: bool) -> str: + script = script or {} + name = html.escape(script.get("name", "") or "") + description = html.escape(script.get("description", "") or "") + script_name = html.escape(script.get("script_name", "") or "") + schedule_type = script.get("schedule_type", "daily") or "daily" + schedule_time = html.escape(script.get("schedule_time", "") or "") + timeout_seconds = html.escape(str(script.get("timeout_seconds") or 300)) + enabled_checked = " checked" if script.get("is_enabled", True) else "" + script_name_input = "" + if include_script_name: + script_name_input = f""" + + + """ + else: + script_name_input = f""" + + + """ + + return f""" +
+ """ + + +@router.get("/scheduled-scripts", response_class=HTMLResponse) +def scheduled_scripts_page(request: Request, user=Depends(require_user)): + scripts = get_scheduled_scripts() + rows = "" + for script in scripts: + script_id_raw = int(script.get("id")) + script_id = html.escape(str(script_id_raw)) + name = html.escape(script.get("name", "") or "") + script_name = html.escape(script.get("script_name", "") or "") + schedule_type = render_schedule_type(script.get("schedule_type")) + schedule_time = html.escape(script.get("schedule_time", "") or "") + last_status = render_status_pill(script.get("last_status")) if script.get("last_status") else "" + last_run_at = html.escape(script.get("last_run_at", "") or "") + next_run_at = html.escape(script.get("next_run_at", "") or "") + enabled = bool(script.get("is_enabled")) + toggle_label = "Zakázat" if enabled else "Povolit" + delete_button = "" + if not script.get("is_running"): + delete_button = f""" + + """ + + rows += f""" +Přehled skriptů spouštěných schedulerem nebo ručně z portálu.
+ +| Název | +Skript | +Frekvence | +Čas | +Zapnuto | +Běží | +Poslední stav | +Poslední běh | +Další běh | +Akce | +
|---|
+ ← Zpět na plánované skripty + Upravit +
+| Název | {html.escape(script.get("name", "") or "")} |
|---|---|
| Popis | {html.escape(script.get("description", "") or "")} |
| Skript | {html.escape(script.get("script_name", "") or "")} |
| Frekvence | {render_schedule_type(script.get("schedule_type"))} |
| Čas | {html.escape(script.get("schedule_time", "") or "")} |
| Timeout | {html.escape(str(script.get("timeout_seconds") or ""))} |
| Zapnuto | {render_bool(script.get("is_enabled"))} |
| Běží | {render_bool(script.get("is_running"))} |
| Poslední stav | {render_status_pill(script.get("last_status")) if script.get("last_status") else ""} |
| Poslední chyba | {html.escape(script.get("last_error_text", "") or "")} |
| Poslední běh | {html.escape(script.get("last_run_at", "") or "")} |
| Další běh | {html.escape(script.get("next_run_at", "") or "")} |
| Poslední úloha | {last_job} |