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""" +
+ + + + + + + {script_name_input} + + + + + + + + + + + + +
+ +
+
+ """ + + +@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""" + + {name} + {script_name} + {schedule_type} + {schedule_time} + {render_enabled_pill(enabled)} + {render_running_pill(script.get("is_running"))} + {last_status} + {last_run_at} + {next_run_at} + + Detail +
+ +
+
+ +
+ {delete_button} + + + """ + + if not rows: + rows = 'Zatím nejsou evidované žádné plánované skripty.' + + return page( + "Plánované skripty", + f""" +
+

Plánované skripty

+

Přehled skriptů spouštěných schedulerem nebo ručně z portálu.

+

+ Nový plánovaný skript

+
+ +
+

Seznam

+ + + + + + + + + + + + + + {rows} +
NázevSkriptFrekvenceČasZapnutoBěžíPoslední stavPoslední běhDalší běhAkce
+
+ """, + user=user, + ) + + +@router.get("/scheduled-scripts/new", response_class=HTMLResponse) +def new_scheduled_script_form(request: Request, user=Depends(require_user)): + return page( + "Nový plánovaný skript", + f""" +
+

Nový plánovaný skript

+

← Zpět

+
+
+ {render_form({"is_enabled": True, "timeout_seconds": 300, "schedule_type": "daily"}, "/portal/scheduled-scripts/new", True)} +
+ """, + user=user, + ) + + +@router.post("/scheduled-scripts/new") +def create_scheduled_script_action( + name: str = Form(...), + description: str = Form(""), + script_name: str = Form(...), + schedule_type: str = Form(...), + schedule_time: str = Form(""), + timeout_seconds: str = Form("300"), + is_enabled: str | None = Form(None), + user=Depends(require_user), +): + metadata = form_metadata(name, description, schedule_type, schedule_time, timeout_seconds, is_enabled) + metadata["script_name"] = validate_script_name(script_name) + script_id = create_scheduled_script(metadata) + log_audit_event( + user, + action="scheduled_script.created", + target_type="scheduled_script", + target_id=script_id, + metadata={"script_name": metadata["script_name"]}, + ) + return RedirectResponse(url=f"/portal/scheduled-scripts/{script_id}", status_code=303) + + +@router.get("/scheduled-scripts/{script_id}", response_class=HTMLResponse) +def scheduled_script_detail(script_id: int, request: Request, user=Depends(require_user)): + script = get_scheduled_script(script_id) + if not script: + raise HTTPException(status_code=404, detail="Scheduled script not found") + + last_job_id = script.get("last_job_id") + last_job = "" + if last_job_id: + last_job_html = html.escape(str(last_job_id)) + last_job = f'#{last_job_html}' + + enabled = bool(script.get("is_enabled")) + toggle_label = "Zakázat" if enabled else "Povolit" + delete_form = "" + if not script.get("is_running"): + delete_form = f""" +
+ +
+ """ + + return page( + html.escape(script.get("name", "") or "Plánovaný skript"), + f""" +
+

{html.escape(script.get("name", "") or "")}

+

+ ← Zpět na plánované skripty + Upravit +

+
+
+ +
+
+ +
+ {delete_form} +
+
+ +
+

Souhrn

+ + + + + + + + + + + + + + +
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}
+
+ """, + user=user, + ) + + +@router.get("/scheduled-scripts/{script_id}/edit", response_class=HTMLResponse) +def edit_scheduled_script_form(script_id: int, request: Request, user=Depends(require_user)): + script = get_scheduled_script(script_id) + if not script: + raise HTTPException(status_code=404, detail="Scheduled script not found") + return page( + "Upravit plánovaný skript", + f""" +
+

Upravit plánovaný skript

+

← Zpět

+
+
+ {render_form(script, f"/portal/scheduled-scripts/{script_id}/edit", False)} +
+ """, + user=user, + ) + + +@router.post("/scheduled-scripts/{script_id}/edit") +def update_scheduled_script_action( + script_id: int, + name: str = Form(...), + description: str = Form(""), + schedule_type: str = Form(...), + schedule_time: str = Form(""), + timeout_seconds: str = Form("300"), + is_enabled: str | None = Form(None), + user=Depends(require_user), +): + script = get_scheduled_script(script_id) + if not script: + raise HTTPException(status_code=404, detail="Scheduled script not found") + metadata = form_metadata(name, description, schedule_type, schedule_time, timeout_seconds, is_enabled) + update_scheduled_script(script_id, metadata) + log_audit_event( + user, + action="scheduled_script.updated", + target_type="scheduled_script", + target_id=script_id, + metadata={"script_name": script.get("script_name")}, + ) + return RedirectResponse(url=f"/portal/scheduled-scripts/{script_id}", status_code=303) + + +@router.post("/scheduled-scripts/{script_id}/run-now") +def run_scheduled_script_now(script_id: int, user=Depends(require_user)): + script = get_scheduled_script(script_id) + if not script: + raise HTTPException(status_code=404, detail="Scheduled script not found") + job_id = create_job( + job_type="run_script", + target_type="scheduled_script", + target_id=script.get("script_name"), + payload={ + "scheduled_script_id": script.get("id"), + "script_name": script.get("script_name"), + "timeout_seconds": script.get("timeout_seconds"), + }, + user=user, + source="portal_manual", + ) + log_audit_event( + user, + action="scheduled_script.run_now", + target_type="scheduled_script", + target_id=script_id, + metadata={"script_name": script.get("script_name"), "job_id": job_id}, + ) + return RedirectResponse(url=f"/portal/jobs/{job_id}", status_code=303) + + +@router.post("/scheduled-scripts/{script_id}/toggle") +def toggle_scheduled_script(script_id: int, user=Depends(require_user)): + script = get_scheduled_script(script_id) + if not script: + raise HTTPException(status_code=404, detail="Scheduled script not found") + enabled = not bool(script.get("is_enabled")) + set_scheduled_script_enabled(script_id, enabled) + log_audit_event( + user, + action="scheduled_script.enabled" if enabled else "scheduled_script.disabled", + target_type="scheduled_script", + target_id=script_id, + metadata={"script_name": script.get("script_name")}, + ) + return RedirectResponse(url=f"/portal/scheduled-scripts/{script_id}", status_code=303) + + +@router.post("/scheduled-scripts/{script_id}/delete") +def delete_scheduled_script_action(script_id: int, user=Depends(require_user)): + script = get_scheduled_script(script_id) + if not script: + raise HTTPException(status_code=404, detail="Scheduled script not found") + if script.get("is_running"): + raise HTTPException(status_code=409, detail="Běžící skript nelze smazat") + if not delete_scheduled_script(script_id): + raise HTTPException(status_code=409, detail="Skript nelze smazat") + log_audit_event( + user, + action="scheduled_script.deleted", + target_type="scheduled_script", + target_id=script_id, + metadata={"script_name": script.get("script_name")}, + ) + return RedirectResponse(url="/portal/scheduled-scripts", status_code=303) diff --git a/app/templates/layout.py b/app/templates/layout.py index 259dad3..487cb46 100644 --- a/app/templates/layout.py +++ b/app/templates/layout.py @@ -20,6 +20,7 @@ def page(title: str, body: str, user=None) -> str: