test scripting changes
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
This commit is contained in:
@@ -18,11 +18,11 @@ import shutil
|
|||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Query
|
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Query
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
# ---------- Configuration ----------
|
# ---------- Configuration ----------
|
||||||
SCRIPT_DIR = "/srv/fw-scripts"
|
SCRIPT_DIR = "/srv/fw-scripts"
|
||||||
@@ -44,6 +44,7 @@ router = APIRouter(prefix="/scripts", tags=["scripts"])
|
|||||||
# ---------- Name validation ----------
|
# ---------- Name validation ----------
|
||||||
_NAME_RE = re.compile(r'^[A-Za-z0-9_.-]+$')
|
_NAME_RE = re.compile(r'^[A-Za-z0-9_.-]+$')
|
||||||
|
|
||||||
|
|
||||||
def validate_name(name: str) -> None:
|
def validate_name(name: str) -> None:
|
||||||
if not name:
|
if not name:
|
||||||
raise ValueError("name must be provided")
|
raise ValueError("name must be provided")
|
||||||
@@ -52,35 +53,43 @@ def validate_name(name: str) -> None:
|
|||||||
if name in (".", ".."):
|
if name in (".", ".."):
|
||||||
raise ValueError("invalid name")
|
raise ValueError("invalid name")
|
||||||
|
|
||||||
|
|
||||||
# ---------- Paths ----------
|
# ---------- Paths ----------
|
||||||
def script_path_for(name: str) -> str:
|
def script_path_for(name: str) -> str:
|
||||||
return os.path.join(SCRIPT_DIR, f"{name}.py")
|
return os.path.join(SCRIPT_DIR, f"{name}.py")
|
||||||
|
|
||||||
|
|
||||||
def requirements_path_for(name: str) -> str:
|
def requirements_path_for(name: str) -> str:
|
||||||
return os.path.join(SCRIPT_DIR, f"{name}-requirements.txt")
|
return os.path.join(SCRIPT_DIR, f"{name}-requirements.txt")
|
||||||
|
|
||||||
|
|
||||||
def venv_path_for(name: str) -> str:
|
def venv_path_for(name: str) -> str:
|
||||||
return os.path.join(VENV_BASE, name)
|
return os.path.join(VENV_BASE, name)
|
||||||
|
|
||||||
|
|
||||||
def venv_python_for(name: str) -> str:
|
def venv_python_for(name: str) -> str:
|
||||||
vpy = os.path.join(venv_path_for(name), "bin", "python")
|
vpy = os.path.join(venv_path_for(name), "bin", "python")
|
||||||
if os.path.exists(vpy):
|
if os.path.exists(vpy):
|
||||||
return vpy
|
return vpy
|
||||||
return "/usr/bin/python3"
|
return "/usr/bin/python3"
|
||||||
|
|
||||||
|
|
||||||
def make_service_name(name: str, qnum: int) -> str:
|
def make_service_name(name: str, qnum: int) -> str:
|
||||||
# safe, deterministic service name
|
# safe, deterministic service name
|
||||||
return f"{UNIT_PREFIX}-{name}-q{qnum}"
|
return f"{UNIT_PREFIX}-{name}-q{qnum}"
|
||||||
|
|
||||||
|
|
||||||
def unit_path_for_name(service_name: str) -> str:
|
def unit_path_for_name(service_name: str) -> str:
|
||||||
# default location for units we write
|
# default location for units we write
|
||||||
return os.path.join(UNIT_DIR, service_name + ".service")
|
return os.path.join(UNIT_DIR, service_name + ".service")
|
||||||
|
|
||||||
|
|
||||||
# ---------- systemd interaction (systemctl-based, no fallback) ----------
|
# ---------- systemd interaction (systemctl-based, no fallback) ----------
|
||||||
def _systemctl_unit_name(unit: str) -> str:
|
def _systemctl_unit_name(unit: str) -> str:
|
||||||
"""Return unit with .service suffix if missing."""
|
"""Return unit with .service suffix if missing."""
|
||||||
return unit if unit.endswith(".service") else unit + ".service"
|
return unit if unit.endswith(".service") else unit + ".service"
|
||||||
|
|
||||||
|
|
||||||
def list_fw_units() -> List[str]:
|
def list_fw_units() -> List[str]:
|
||||||
"""
|
"""
|
||||||
Return list of systemd units (without .service suffix) whose name starts with UNIT_PREFIX-.
|
Return list of systemd units (without .service suffix) whose name starts with UNIT_PREFIX-.
|
||||||
@@ -88,8 +97,13 @@ def list_fw_units() -> List[str]:
|
|||||||
"""
|
"""
|
||||||
units: List[str] = []
|
units: List[str] = []
|
||||||
try:
|
try:
|
||||||
p = subprocess.run(["systemctl", "list-units", "--type=service", "--all", "--no-legend"],
|
p = subprocess.run(
|
||||||
capture_output=True, text=True, check=False, timeout=3)
|
["systemctl", "list-units", "--type=service", "--all", "--no-legend"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
timeout=3,
|
||||||
|
)
|
||||||
out = p.stdout or ""
|
out = p.stdout or ""
|
||||||
# each line starts with unit name
|
# each line starts with unit name
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
@@ -104,6 +118,7 @@ def list_fw_units() -> List[str]:
|
|||||||
logger.exception("systemctl list-units failed")
|
logger.exception("systemctl list-units failed")
|
||||||
return units
|
return units
|
||||||
|
|
||||||
|
|
||||||
def get_unit_fragment_path(service_name: str) -> Optional[str]:
|
def get_unit_fragment_path(service_name: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Use `systemctl show -p FragmentPath --value` to obtain the unit file path (if any).
|
Use `systemctl show -p FragmentPath --value` to obtain the unit file path (if any).
|
||||||
@@ -121,13 +136,28 @@ def get_unit_fragment_path(service_name: str) -> Optional[str]:
|
|||||||
logger.exception("systemctl show FragmentPath failed for %s", service_name)
|
logger.exception("systemctl show FragmentPath failed for %s", service_name)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def parse_unit_execstart(service_name: str) -> Dict:
|
|
||||||
|
class UnitParsed(BaseModel):
|
||||||
|
service: str
|
||||||
|
exec_start: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
script_path: Optional[str] = None
|
||||||
|
qnum: Optional[int] = None
|
||||||
|
extra: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UnitStatus(BaseModel):
|
||||||
|
parsed: UnitParsed
|
||||||
|
active: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_unit_execstart(service_name: str) -> UnitParsed:
|
||||||
"""
|
"""
|
||||||
Parser for ExecStart using systemctl --no-pager show.
|
Parser for ExecStart using systemctl --no-pager show.
|
||||||
- reconstructs argv[] entries if systemd returns a structured blob
|
- reconstructs argv[] entries if systemd returns a structured blob
|
||||||
- finds /srv/fw-scripts/<name>.py anywhere in the commandline
|
- finds /srv/fw-scripts/<name>.py anywhere in the commandline
|
||||||
- extracts the first integer after the script as qnum
|
- extracts the first integer after the script as qnum
|
||||||
Returns a dict: {service, exec_start, name, script_path, qnum, extra}
|
Returns a UnitParsed instance.
|
||||||
"""
|
"""
|
||||||
unit = service_name if service_name.endswith(".service") else service_name + ".service"
|
unit = service_name if service_name.endswith(".service") else service_name + ".service"
|
||||||
|
|
||||||
@@ -142,31 +172,17 @@ def parse_unit_execstart(service_name: str) -> Dict:
|
|||||||
raw = (p.stdout or "").strip()
|
raw = (p.stdout or "").strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("systemctl show ExecStart failed for %s", service_name)
|
logger.exception("systemctl show ExecStart failed for %s", service_name)
|
||||||
return {
|
return UnitParsed(service=service_name)
|
||||||
"service": service_name,
|
|
||||||
"exec_start": None,
|
|
||||||
"name": None,
|
|
||||||
"script_path": None,
|
|
||||||
"qnum": None,
|
|
||||||
"extra": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if not raw:
|
if not raw:
|
||||||
return {
|
return UnitParsed(service=service_name)
|
||||||
"service": service_name,
|
|
||||||
"exec_start": None,
|
|
||||||
"name": None,
|
|
||||||
"script_path": None,
|
|
||||||
"qnum": None,
|
|
||||||
"extra": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
exec_start = raw
|
exec_start: str = raw
|
||||||
|
|
||||||
# If structured with argv[] entries, extract them (allow newlines).
|
# If structured with argv[] entries, extract them (allow newlines).
|
||||||
if "argv[]=" in raw:
|
if "argv[]=" in raw:
|
||||||
argv_entries = re.findall(r'argv\[\]=([^;]+)', raw, flags=re.DOTALL)
|
argv_entries = re.findall(r'argv\[\]=([^;]+)', raw, flags=re.DOTALL)
|
||||||
argv_parts = []
|
argv_parts: List[str] = []
|
||||||
for a in argv_entries:
|
for a in argv_entries:
|
||||||
v = a.strip()
|
v = a.strip()
|
||||||
# strip surrounding quotes if present
|
# strip surrounding quotes if present
|
||||||
@@ -181,22 +197,15 @@ def parse_unit_execstart(service_name: str) -> Dict:
|
|||||||
m = re.search(r'(/srv/fw-scripts/(?P<name>[A-Za-z0-9_.-]+)\.py)\b', exec_start)
|
m = re.search(r'(/srv/fw-scripts/(?P<name>[A-Za-z0-9_.-]+)\.py)\b', exec_start)
|
||||||
if not m:
|
if not m:
|
||||||
# return raw exec_start but no parsed metadata
|
# return raw exec_start but no parsed metadata
|
||||||
return {
|
return UnitParsed(service=service_name, exec_start=exec_start)
|
||||||
"service": service_name,
|
|
||||||
"exec_start": exec_start,
|
|
||||||
"name": None,
|
|
||||||
"script_path": None,
|
|
||||||
"qnum": None,
|
|
||||||
"extra": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
script_path = m.group(1)
|
script_path = m.group(1)
|
||||||
name = m.group("name")
|
name = m.group("name")
|
||||||
|
|
||||||
# Find first integer token after the script path (queue number)
|
# Find first integer token after the script path (queue number)
|
||||||
after = exec_start[m.end():].strip()
|
after = exec_start[m.end():].strip()
|
||||||
qnum = None
|
qnum: Optional[int] = None
|
||||||
extra = None
|
extra: Optional[str] = None
|
||||||
if after:
|
if after:
|
||||||
tokens = after.split()
|
tokens = after.split()
|
||||||
for i, t in enumerate(tokens):
|
for i, t in enumerate(tokens):
|
||||||
@@ -204,19 +213,12 @@ def parse_unit_execstart(service_name: str) -> Dict:
|
|||||||
val = int(t)
|
val = int(t)
|
||||||
if 0 <= val <= 65535:
|
if 0 <= val <= 65535:
|
||||||
qnum = val
|
qnum = val
|
||||||
extra = " ".join(tokens[i+1:]) if i+1 < len(tokens) else ""
|
extra = " ".join(tokens[i+1:]) if i + 1 < len(tokens) else ""
|
||||||
break
|
break
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
return {
|
return UnitParsed(service=service_name, exec_start=exec_start, name=name, script_path=script_path, qnum=qnum, extra=extra)
|
||||||
"service": service_name,
|
|
||||||
"exec_start": exec_start,
|
|
||||||
"name": name,
|
|
||||||
"script_path": script_path,
|
|
||||||
"qnum": qnum,
|
|
||||||
"extra": extra,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def is_unit_active(service_name: str) -> bool:
|
def is_unit_active(service_name: str) -> bool:
|
||||||
@@ -224,9 +226,11 @@ def is_unit_active(service_name: str) -> bool:
|
|||||||
p = subprocess.run(["systemctl", "is-active", "--quiet", unit])
|
p = subprocess.run(["systemctl", "is-active", "--quiet", unit])
|
||||||
return p.returncode == 0
|
return p.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def write_unit(service_name: str, exec_start: str, description: str = "", enable_at_boot: bool = False) -> str:
|
def write_unit(service_name: str, exec_start: str, description: str = "", enable_at_boot: bool = False) -> str:
|
||||||
"""
|
"""
|
||||||
Write unit file to default UNIT_DIR and daemon-reload. This writes to disk as systemd expects.
|
Write unit file to default UNIT_DIR and daemon-reload. This writes to disk as systemd expects.
|
||||||
|
Returns the path to the unit file written.
|
||||||
"""
|
"""
|
||||||
unit_path = unit_path_for_name(service_name)
|
unit_path = unit_path_for_name(service_name)
|
||||||
unit_text = f"""[Unit]
|
unit_text = f"""[Unit]
|
||||||
@@ -256,18 +260,22 @@ WantedBy=multi-user.target
|
|||||||
logger.warning("Failed to enable %s at boot", service_name)
|
logger.warning("Failed to enable %s at boot", service_name)
|
||||||
return unit_path
|
return unit_path
|
||||||
|
|
||||||
|
|
||||||
def start_unit(service_name: str) -> None:
|
def start_unit(service_name: str) -> None:
|
||||||
subprocess.run(["systemctl", "start", _systemctl_unit_name(service_name)], check=True)
|
subprocess.run(["systemctl", "start", _systemctl_unit_name(service_name)], check=True)
|
||||||
logger.info("Started service %s", service_name)
|
logger.info("Started service %s", service_name)
|
||||||
|
|
||||||
|
|
||||||
def stop_unit(service_name: str) -> None:
|
def stop_unit(service_name: str) -> None:
|
||||||
subprocess.run(["systemctl", "stop", _systemctl_unit_name(service_name)], check=True)
|
subprocess.run(["systemctl", "stop", _systemctl_unit_name(service_name)], check=True)
|
||||||
logger.info("Stopped service %s", service_name)
|
logger.info("Stopped service %s", service_name)
|
||||||
|
|
||||||
|
|
||||||
def disable_unit(service_name: str) -> None:
|
def disable_unit(service_name: str) -> None:
|
||||||
subprocess.run(["systemctl", "disable", _systemctl_unit_name(service_name)], check=False)
|
subprocess.run(["systemctl", "disable", _systemctl_unit_name(service_name)], check=False)
|
||||||
logger.info("Disabled service %s", service_name)
|
logger.info("Disabled service %s", service_name)
|
||||||
|
|
||||||
|
|
||||||
def remove_unit(service_name: str) -> None:
|
def remove_unit(service_name: str) -> None:
|
||||||
"""
|
"""
|
||||||
Stop + disable the unit, remove the unit file using FragmentPath (if present),
|
Stop + disable the unit, remove the unit file using FragmentPath (if present),
|
||||||
@@ -300,6 +308,7 @@ def remove_unit(service_name: str) -> None:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("daemon-reload failed after removing unit %s", service_name)
|
logger.exception("daemon-reload failed after removing unit %s", service_name)
|
||||||
|
|
||||||
|
|
||||||
# ---------- venv + pip helpers ----------
|
# ---------- venv + pip helpers ----------
|
||||||
def create_venv(name: str, timeout: int = 60) -> str:
|
def create_venv(name: str, timeout: int = 60) -> str:
|
||||||
venv_dir = venv_path_for(name)
|
venv_dir = venv_path_for(name)
|
||||||
@@ -318,6 +327,16 @@ def create_venv(name: str, timeout: int = 60) -> str:
|
|||||||
raise RuntimeError("venv creation timed out")
|
raise RuntimeError("venv creation timed out")
|
||||||
return venv_dir
|
return venv_dir
|
||||||
|
|
||||||
|
|
||||||
|
def jsonify_cmd_output(out: Dict[str, str]) -> str:
|
||||||
|
s = ""
|
||||||
|
if out.get("stdout"):
|
||||||
|
s += "STDOUT:\n" + out["stdout"] + "\n"
|
||||||
|
if out.get("stderr"):
|
||||||
|
s += "STDERR:\n" + out["stderr"] + "\n"
|
||||||
|
return s.strip()
|
||||||
|
|
||||||
|
|
||||||
def pip_install_requirements(name: str, requirements_path: str, timeout: int = 600) -> Dict[str, str]:
|
def pip_install_requirements(name: str, requirements_path: str, timeout: int = 600) -> Dict[str, str]:
|
||||||
venv_dir = create_venv(name)
|
venv_dir = create_venv(name)
|
||||||
pip_path = os.path.join(venv_dir, "bin", "pip")
|
pip_path = os.path.join(venv_dir, "bin", "pip")
|
||||||
@@ -338,53 +357,72 @@ def pip_install_requirements(name: str, requirements_path: str, timeout: int = 6
|
|||||||
logger.error("pip install timed out for %s", name)
|
logger.error("pip install timed out for %s", name)
|
||||||
raise RuntimeError("pip install timed out")
|
raise RuntimeError("pip install timed out")
|
||||||
|
|
||||||
def jsonify_cmd_output(out: Dict[str, str]) -> str:
|
|
||||||
s = ""
|
|
||||||
if out.get("stdout"):
|
|
||||||
s += "STDOUT:\n" + out["stdout"] + "\n"
|
|
||||||
if out.get("stderr"):
|
|
||||||
s += "STDERR:\n" + out["stderr"] + "\n"
|
|
||||||
return s.strip()
|
|
||||||
|
|
||||||
# ---------- Models ----------
|
# ---------- Models ----------
|
||||||
class ScriptInfo(BaseModel):
|
class ScriptInfo(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
path: str
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptUploadResponse(ScriptInfo):
|
||||||
|
pip: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
class EnableRequest(BaseModel):
|
class EnableRequest(BaseModel):
|
||||||
qnum: int
|
qnum: int = Field(..., description="Queue number (integer)")
|
||||||
service_name: Optional[str] = None
|
service_name: Optional[str] = None
|
||||||
extra_args: Optional[str] = None
|
extra_args: Optional[str] = None
|
||||||
enable_at_boot: Optional[bool] = False
|
enable_at_boot: Optional[bool] = False
|
||||||
|
|
||||||
|
|
||||||
|
class UnitMapping(BaseModel):
|
||||||
|
service: str
|
||||||
|
parsed: UnitParsed
|
||||||
|
active: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class StatusForNameResponse(BaseModel):
|
||||||
|
name: str
|
||||||
|
mappings: List[UnitMapping]
|
||||||
|
|
||||||
|
|
||||||
|
class OperationResult(BaseModel):
|
||||||
|
status: str
|
||||||
|
name: str
|
||||||
|
qnum: Optional[int] = None
|
||||||
|
service: Optional[str] = None
|
||||||
|
python: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# ---------- Endpoints ----------
|
# ---------- Endpoints ----------
|
||||||
# Note: Place status endpoints before the dynamic GET /{name} route to avoid routing conflicts.
|
# Note: Place status endpoints before the dynamic GET /{name} route to avoid routing conflicts.
|
||||||
|
|
||||||
@router.get("/status")
|
@router.get("/status", response_model=Dict[str, UnitStatus])
|
||||||
def status_all():
|
def status_all() -> Dict[str, UnitStatus]:
|
||||||
"""
|
"""
|
||||||
Discover all fw-script units via systemctl and report parsed ExecStart + active state.
|
Discover all fw-script units via systemctl and report parsed ExecStart + active state.
|
||||||
"""
|
"""
|
||||||
units = list_fw_units()
|
units = list_fw_units()
|
||||||
results: Dict[str, Dict] = {}
|
results: Dict[str, UnitStatus] = {}
|
||||||
for svc in units:
|
for svc in units:
|
||||||
parsed = parse_unit_execstart(svc)
|
parsed = parse_unit_execstart(svc)
|
||||||
try:
|
try:
|
||||||
active = is_unit_active(svc)
|
active = is_unit_active(svc)
|
||||||
except Exception:
|
except Exception:
|
||||||
active = False
|
active = False
|
||||||
results[svc] = {"parsed": parsed, "active": active}
|
results[svc] = UnitStatus(parsed=parsed, active=active)
|
||||||
logger.debug("Status queried: found %d units", len(results))
|
logger.debug("Status queried: found %d units", len(results))
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@router.get("/{name}/status")
|
|
||||||
def status_for_name(name: str):
|
@router.get("/{name}/status", response_model=StatusForNameResponse)
|
||||||
|
def status_for_name(name: str) -> StatusForNameResponse:
|
||||||
try:
|
try:
|
||||||
validate_name(name)
|
validate_name(name)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
units = list_fw_units()
|
units = list_fw_units()
|
||||||
matches = []
|
matches: List[UnitMapping] = []
|
||||||
prefix = f"{UNIT_PREFIX}-{name}-q"
|
prefix = f"{UNIT_PREFIX}-{name}-q"
|
||||||
for svc in units:
|
for svc in units:
|
||||||
if svc.startswith(prefix):
|
if svc.startswith(prefix):
|
||||||
@@ -393,16 +431,17 @@ def status_for_name(name: str):
|
|||||||
active = is_unit_active(svc)
|
active = is_unit_active(svc)
|
||||||
except Exception:
|
except Exception:
|
||||||
active = False
|
active = False
|
||||||
matches.append({"service": svc, "parsed": parsed, "active": active})
|
matches.append(UnitMapping(service=svc, parsed=parsed, active=active))
|
||||||
logger.debug("Status for %s -> %d matches", name, len(matches))
|
logger.debug("Status for %s -> %d matches", name, len(matches))
|
||||||
return {"name": name, "mappings": matches}
|
return StatusForNameResponse(name=name, mappings=matches)
|
||||||
|
|
||||||
@router.post("", response_model=ScriptInfo)
|
|
||||||
|
@router.post("", response_model=ScriptUploadResponse)
|
||||||
async def upload_script(
|
async def upload_script(
|
||||||
script: UploadFile = File(...),
|
script: UploadFile = File(...),
|
||||||
name: str = Form(...),
|
name: str = Form(...),
|
||||||
requirements: Optional[UploadFile] = File(None),
|
requirements: Optional[UploadFile] = File(None),
|
||||||
):
|
) -> ScriptUploadResponse:
|
||||||
"""
|
"""
|
||||||
Upload a script with supplied 'name' and optional requirements file.
|
Upload a script with supplied 'name' and optional requirements file.
|
||||||
On pip/venv install failure, cleanup uploaded files and venv and return 500 with details.
|
On pip/venv install failure, cleanup uploaded files and venv and return 500 with details.
|
||||||
@@ -433,8 +472,8 @@ async def upload_script(
|
|||||||
logger.exception("Failed to write script file for %s: %s", name, e)
|
logger.exception("Failed to write script file for %s: %s", name, e)
|
||||||
raise HTTPException(status_code=500, detail="failed to save script")
|
raise HTTPException(status_code=500, detail="failed to save script")
|
||||||
|
|
||||||
req_path = None
|
req_path: Optional[str] = None
|
||||||
pip_output = None
|
pip_output: Optional[Dict[str, str]] = None
|
||||||
venv_created = False
|
venv_created = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -488,19 +527,21 @@ async def upload_script(
|
|||||||
resp = {"name": name, "path": spath}
|
resp = {"name": name, "path": spath}
|
||||||
if pip_output is not None:
|
if pip_output is not None:
|
||||||
resp["pip"] = pip_output
|
resp["pip"] = pip_output
|
||||||
return resp
|
return ScriptUploadResponse(**resp)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[ScriptInfo])
|
@router.get("", response_model=List[ScriptInfo])
|
||||||
def list_scripts():
|
def list_scripts() -> List[ScriptInfo]:
|
||||||
out = []
|
out: List[ScriptInfo] = []
|
||||||
for fn in os.listdir(SCRIPT_DIR):
|
for fn in os.listdir(SCRIPT_DIR):
|
||||||
if not fn.endswith(".py"):
|
if not fn.endswith(".py"):
|
||||||
continue
|
continue
|
||||||
name = fn.rsplit(".", 1)[0]
|
name = fn.rsplit(".", 1)[0]
|
||||||
out.append({"name": name, "path": os.path.join(SCRIPT_DIR, fn)})
|
out.append(ScriptInfo(name=name, path=os.path.join(SCRIPT_DIR, fn)))
|
||||||
logger.debug("Listed %d scripts", len(out))
|
logger.debug("Listed %d scripts", len(out))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{name}")
|
@router.get("/{name}")
|
||||||
def download_script(name: str):
|
def download_script(name: str):
|
||||||
# dynamic route - placed after /status and /{name}/status
|
# dynamic route - placed after /status and /{name}/status
|
||||||
@@ -515,8 +556,9 @@ def download_script(name: str):
|
|||||||
logger.info("Download script %s", name)
|
logger.info("Download script %s", name)
|
||||||
return FileResponse(path, media_type="text/x-python", filename=f"{name}.py")
|
return FileResponse(path, media_type="text/x-python", filename=f"{name}.py")
|
||||||
|
|
||||||
@router.post("/{name}/enable")
|
|
||||||
def enable_script(name: str, req: EnableRequest):
|
@router.post("/{name}/enable", response_model=OperationResult)
|
||||||
|
def enable_script(name: str, req: EnableRequest) -> OperationResult:
|
||||||
try:
|
try:
|
||||||
validate_name(name)
|
validate_name(name)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -534,7 +576,7 @@ def enable_script(name: str, req: EnableRequest):
|
|||||||
exec_start += " " + req.extra_args
|
exec_start += " " + req.extra_args
|
||||||
|
|
||||||
try:
|
try:
|
||||||
write_unit(service_name, exec_start, description=f"FW script {name} queue {qnum}", enable_at_boot=req.enable_at_boot)
|
write_unit(service_name, exec_start, description=f"FW script {name} queue {qnum}", enable_at_boot=req.enable_at_boot or False)
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
start_unit(service_name)
|
start_unit(service_name)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
@@ -553,10 +595,11 @@ def enable_script(name: str, req: EnableRequest):
|
|||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
logger.info("Enabled script %s on qnum=%d as service=%s (python=%s)", name, qnum, service_name, python_path)
|
logger.info("Enabled script %s on qnum=%d as service=%s (python=%s)", name, qnum, service_name, python_path)
|
||||||
return {"status": "ok", "name": name, "qnum": qnum, "service": service_name, "python": python_path}
|
return OperationResult(status="ok", name=name, qnum=qnum, service=service_name, python=python_path)
|
||||||
|
|
||||||
@router.post("/{name}/disable")
|
|
||||||
def disable_script(name: str, qnum: int):
|
@router.post("/{name}/disable", response_model=OperationResult)
|
||||||
|
def disable_script(name: str, qnum: int) -> OperationResult:
|
||||||
try:
|
try:
|
||||||
validate_name(name)
|
validate_name(name)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -576,10 +619,11 @@ def disable_script(name: str, qnum: int):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Failed to disable unit %s: %s", service_name, e)
|
logger.exception("Failed to disable unit %s: %s", service_name, e)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
return {"status": "ok", "name": name, "qnum": qnum}
|
return OperationResult(status="ok", name=name, qnum=qnum, service=service_name)
|
||||||
|
|
||||||
@router.delete("/{name}")
|
|
||||||
def delete_script(name: str, qnum: Optional[int] = Query(None, description="If given, only remove the unit for this qnum; otherwise remove all units for the script")):
|
@router.delete("/{name}", response_model=Dict[str, Any])
|
||||||
|
def delete_script(name: str, qnum: Optional[int] = Query(None, description="If given, only remove the unit for this qnum; otherwise remove all units for the script")) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Delete a script and its associated resources.
|
Delete a script and its associated resources.
|
||||||
- If qnum is provided: stop/remove fw-script-<name>-q<qnum>.service (if present).
|
- If qnum is provided: stop/remove fw-script-<name>-q<qnum>.service (if present).
|
||||||
@@ -624,8 +668,8 @@ def delete_script(name: str, qnum: Optional[int] = Query(None, description="If g
|
|||||||
spath = script_path_for(name)
|
spath = script_path_for(name)
|
||||||
rpath = requirements_path_for(name)
|
rpath = requirements_path_for(name)
|
||||||
vpath = venv_path_for(name)
|
vpath = venv_path_for(name)
|
||||||
file_removed = []
|
file_removed: List[str] = []
|
||||||
file_failed = []
|
file_failed: List[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if os.path.exists(spath):
|
if os.path.exists(spath):
|
||||||
@@ -657,7 +701,7 @@ def delete_script(name: str, qnum: Optional[int] = Query(None, description="If g
|
|||||||
file_failed.append(vpath)
|
file_failed.append(vpath)
|
||||||
errors.append(f"remove_venv {vpath}: {e}")
|
errors.append(f"remove_venv {vpath}: {e}")
|
||||||
|
|
||||||
result = {
|
result: Dict[str, Any] = {
|
||||||
"name": name,
|
"name": name,
|
||||||
"units_removed": removed_units,
|
"units_removed": removed_units,
|
||||||
"units_failed": failed_units,
|
"units_failed": failed_units,
|
||||||
@@ -668,10 +712,11 @@ def delete_script(name: str, qnum: Optional[int] = Query(None, description="If g
|
|||||||
logger.info("Delete script %s completed: removed_units=%d files_removed=%d errors=%d", name, len(removed_units), len(file_removed), len(errors))
|
logger.info("Delete script %s completed: removed_units=%d files_removed=%d errors=%d", name, len(removed_units), len(file_removed), len(errors))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---------- Lifecycle helper ----------
|
# ---------- Lifecycle helper ----------
|
||||||
def register_lifecycle(app):
|
def register_lifecycle(app) -> None:
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
def _shutdown_event():
|
def _shutdown_event() -> None:
|
||||||
logger.info("Shutdown: stopping/removing manager-created units with prefix %s", UNIT_PREFIX)
|
logger.info("Shutdown: stopping/removing manager-created units with prefix %s", UNIT_PREFIX)
|
||||||
units = list_fw_units()
|
units = list_fw_units()
|
||||||
for svc in units:
|
for svc in units:
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ import {
|
|||||||
InterfaceInfo,
|
InterfaceInfo,
|
||||||
RouteInfo,
|
RouteInfo,
|
||||||
} from '../types/network';
|
} from '../types/network';
|
||||||
import { EnableRequest, ScriptInfo } from '../types/scripting';
|
import {
|
||||||
|
DeleteResult,
|
||||||
|
EnableRequest,
|
||||||
|
OperationResult,
|
||||||
|
ScriptInfo,
|
||||||
|
ScriptUploadResponse,
|
||||||
|
StatusForNameResponse,
|
||||||
|
UnitStatus,
|
||||||
|
} from '../types/scripting';
|
||||||
import { SnifferStatusResponse } from '../types/sniffer';
|
import { SnifferStatusResponse } from '../types/sniffer';
|
||||||
|
|
||||||
const BASE = 'http://mitm.lan/api';
|
const BASE = 'http://mitm.lan/api';
|
||||||
@@ -17,7 +25,7 @@ const BASE = 'http://mitm.lan/api';
|
|||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: BASE,
|
baseURL: BASE,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
timeout: 10000,
|
timeout: 20000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Normalize FastAPI errors here
|
// Normalize FastAPI errors here
|
||||||
@@ -160,13 +168,13 @@ export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
|
|||||||
Scripts
|
Scripts
|
||||||
------------------------- */
|
------------------------- */
|
||||||
|
|
||||||
export const fetchScriptsStatusAll = async (): Promise<any> => {
|
export const fetchScriptsStatusAll = async (): Promise<Record<string, UnitStatus>> => {
|
||||||
const res = await api.get('/scripts/scripts/status');
|
const res = await api.get<Record<string, UnitStatus>>('/scripts/scripts/status');
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchScriptStatusForName = async (name: string): Promise<any> => {
|
export const fetchScriptStatusForName = async (name: string): Promise<StatusForNameResponse> => {
|
||||||
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/status`);
|
const res = await api.get<StatusForNameResponse>(`/scripts/scripts/${encodeURIComponent(name)}/status`);
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -179,38 +187,40 @@ export const uploadScript = async (opts: {
|
|||||||
name: string;
|
name: string;
|
||||||
script: File | Blob;
|
script: File | Blob;
|
||||||
requirements?: File | Blob | null;
|
requirements?: File | Blob | null;
|
||||||
}): Promise<ScriptInfo> => {
|
}): Promise<ScriptUploadResponse> => {
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.append('name', opts.name);
|
fd.append('name', opts.name);
|
||||||
fd.append('script', opts.script);
|
fd.append('script', opts.script);
|
||||||
if (opts.requirements) fd.append('requirements', opts.requirements as Blob);
|
if (opts.requirements) fd.append('requirements', opts.requirements as Blob);
|
||||||
|
|
||||||
// axios will set multipart/form-data boundary automatically when FormData passed
|
// axios will set multipart/form-data boundary automatically when FormData passed
|
||||||
const res = await api.post<ScriptInfo>('/scripts/scripts', fd, {
|
const res = await api.post<ScriptUploadResponse>('/scripts/scripts', fd, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const downloadScript = async (name: string): Promise<any> => {
|
export const downloadScript = async (name: string): Promise<Blob> => {
|
||||||
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`);
|
// expect the backend to return a FileResponse. request as blob to trigger browser download handling.
|
||||||
return res.data;
|
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`, { responseType: 'blob' });
|
||||||
|
return res.data as Blob;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const deleteScript = async (name: string, qnum?: number | null): Promise<any> => {
|
export const deleteScript = async (name: string, qnum?: number | null): Promise<DeleteResult> => {
|
||||||
const params: Record<string, any> = {};
|
const params: Record<string, any> = {};
|
||||||
if (typeof qnum !== 'undefined') params.qnum = qnum;
|
if (typeof qnum !== 'undefined' && qnum !== null) params.qnum = qnum;
|
||||||
const res = await api.delete(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
|
const res = await api.delete<DeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const enableScript = async (name: string, req: EnableRequest): Promise<any> => {
|
export const enableScript = async (name: string, req: EnableRequest): Promise<OperationResult> => {
|
||||||
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
|
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const disableScript = async (name: string, qnum: number): Promise<any> => {
|
export const disableScript = async (name: string, qnum: number): Promise<OperationResult> => {
|
||||||
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
|
// backend expects qnum as query param (POST body null)
|
||||||
|
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
|
||||||
params: { qnum },
|
params: { qnum },
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type PythonEditorProps = {
|
|||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
height?: string | number; // e.g. '200px' or 200
|
height?: string | number; // e.g. '200px' or 200
|
||||||
|
wrap?: boolean; // if true, long lines will wrap; default false (no wrap)
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PythonEditor({
|
export default function PythonEditor({
|
||||||
@@ -19,6 +20,7 @@ export default function PythonEditor({
|
|||||||
readOnly = false,
|
readOnly = false,
|
||||||
height = 480,
|
height = 480,
|
||||||
className = '',
|
className = '',
|
||||||
|
wrap = false,
|
||||||
}: PythonEditorProps) {
|
}: PythonEditorProps) {
|
||||||
const highlight = useCallback((code: string) => {
|
const highlight = useCallback((code: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -28,22 +30,43 @@ export default function PythonEditor({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// container style: fixed height + scrolling
|
||||||
|
const containerStyle = useMemo(() => {
|
||||||
|
const h = typeof height === 'number' ? `${height}px` : height;
|
||||||
|
return {
|
||||||
|
height: h,
|
||||||
|
maxHeight: h,
|
||||||
|
overflow: 'auto' as const, // enable scrolling
|
||||||
|
position: 'relative' as const,
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid rgba(0,0,0,0.06)',
|
||||||
|
background: 'var(--code-bg, white)',
|
||||||
|
};
|
||||||
|
}, [height]);
|
||||||
|
|
||||||
|
// editor style: fill container and use monospace font
|
||||||
const editorStyle = useMemo(
|
const editorStyle = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
height: typeof height === 'number' ? `${height}px` : height,
|
// make editor fill the container (pre/textarea inside will take up height)
|
||||||
|
minHeight: '100%',
|
||||||
|
boxSizing: 'border-box' as const,
|
||||||
|
padding: 12,
|
||||||
|
outline: 'none',
|
||||||
|
whiteSpace: wrap ? 'pre-wrap' : 'pre', // pre-wrap if wrap requested
|
||||||
|
wordBreak: 'break-word' as const,
|
||||||
}),
|
}),
|
||||||
[height],
|
[wrap],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`python-editor ${className}`}>
|
<div className={`python-editor ${className}`} style={containerStyle}>
|
||||||
<Editor
|
<Editor
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={(code) => onChange(code)}
|
onValueChange={(code) => onChange(code)}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
padding={12}
|
padding={0} // padding handled by editorStyle
|
||||||
preClassName="language-python"
|
preClassName="language-python"
|
||||||
textareaId="python-editor"
|
textareaId="python-editor"
|
||||||
style={editorStyle}
|
style={editorStyle}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Spin,
|
Spin,
|
||||||
Table,
|
Table,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Typography,
|
||||||
Upload,
|
Upload,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
@@ -35,12 +36,36 @@ import {
|
|||||||
uploadScript,
|
uploadScript,
|
||||||
} from '../api/apiClient';
|
} from '../api/apiClient';
|
||||||
import PythonEditor from '../components/PythonEditor';
|
import PythonEditor from '../components/PythonEditor';
|
||||||
import { EnableRequest, ScriptInfo } from '../types/scripting';
|
import {
|
||||||
|
DeleteResult,
|
||||||
|
EnableRequest,
|
||||||
|
OperationResult,
|
||||||
|
ScriptInfo,
|
||||||
|
ScriptUploadResponse,
|
||||||
|
UnitStatus,
|
||||||
|
} from '../types/scripting';
|
||||||
|
|
||||||
|
const { Paragraph } = Typography;
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onOpenInEditor?: (code: string) => void;
|
onOpenInEditor?: (code: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UnitParsedLocal = {
|
||||||
|
service: string;
|
||||||
|
exec_start?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
script_path?: string | null;
|
||||||
|
qnum?: number | null;
|
||||||
|
extra?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UnitMapping = {
|
||||||
|
service: string;
|
||||||
|
parsed: UnitParsedLocal;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ScriptsManager({ onOpenInEditor }: Props) {
|
export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||||
const [scripts, setScripts] = useState<ScriptInfo[]>([]);
|
const [scripts, setScripts] = useState<ScriptInfo[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -49,11 +74,19 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
const [editorValue, setEditorValue] = useState<string>('');
|
const [editorValue, setEditorValue] = useState<string>('');
|
||||||
const [currentEditingName, setCurrentEditingName] = useState<string | null>(null);
|
const [currentEditingName, setCurrentEditingName] = useState<string | null>(null);
|
||||||
const [fetchingStatuses, setFetchingStatuses] = useState(false);
|
const [fetchingStatuses, setFetchingStatuses] = useState(false);
|
||||||
|
const [statuses, setStatuses] = useState<Record<string, UnitStatus> | null>(null);
|
||||||
|
|
||||||
|
// pip output modal
|
||||||
|
const [pipModalVisible, setPipModalVisible] = useState(false);
|
||||||
|
const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null);
|
||||||
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [enableForm] = Form.useForm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshList();
|
refreshList();
|
||||||
|
// optional: fetch statuses immediately as well
|
||||||
|
refreshStatuses();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function refreshList() {
|
async function refreshList() {
|
||||||
@@ -72,9 +105,9 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
setFetchingStatuses(true);
|
setFetchingStatuses(true);
|
||||||
try {
|
try {
|
||||||
const all = await fetchScriptsStatusAll();
|
const all = await fetchScriptsStatusAll();
|
||||||
// assume the API returns keyed info matching script names. adapt as needed
|
setStatuses(all);
|
||||||
// For simplicity, re-fetch list (which may include status), but if you have separate structure, merge.
|
const count = all ? Object.keys(all).length : 0;
|
||||||
await refreshList();
|
notification.info({ message: 'Statuses fetched', description: `Found ${count} service(s)` });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
notification.error({ message: 'Failed to fetch statuses', description: err?.message || String(err) });
|
notification.error({ message: 'Failed to fetch statuses', description: err?.message || String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -82,29 +115,45 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: collect UnitMapping[] for a given script name from statuses
|
||||||
|
const getMappingsForScript = (scriptName: string): UnitMapping[] => {
|
||||||
|
if (!statuses) return [];
|
||||||
|
const mappings: UnitMapping[] = [];
|
||||||
|
for (const svcKey of Object.keys(statuses)) {
|
||||||
|
const us = statuses[svcKey];
|
||||||
|
const parsed = us?.parsed as any;
|
||||||
|
// Prefer matching parsed.name if present, otherwise infer by service string
|
||||||
|
const parsedName = parsed?.name ?? null;
|
||||||
|
if (parsedName === scriptName || svcKey.includes(`-${scriptName}-q`)) {
|
||||||
|
mappings.push({
|
||||||
|
service: svcKey,
|
||||||
|
parsed: {
|
||||||
|
service: parsed?.service ?? svcKey,
|
||||||
|
exec_start: parsed?.exec_start ?? null,
|
||||||
|
name: parsed?.name ?? null,
|
||||||
|
script_path: parsed?.script_path ?? null,
|
||||||
|
qnum: typeof parsed?.qnum === 'number' ? parsed.qnum : null,
|
||||||
|
extra: parsed?.extra ?? null,
|
||||||
|
},
|
||||||
|
active: !!us?.active,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// sort by qnum (nulls last)
|
||||||
|
mappings.sort((a, b) => {
|
||||||
|
const aq = a.parsed.qnum;
|
||||||
|
const bq = b.parsed.qnum;
|
||||||
|
if (aq == null && bq == null) return 0;
|
||||||
|
if (aq == null) return 1;
|
||||||
|
if (bq == null) return -1;
|
||||||
|
return aq - bq;
|
||||||
|
});
|
||||||
|
return mappings;
|
||||||
|
};
|
||||||
|
|
||||||
async function handleDownload(name: string) {
|
async function handleDownload(name: string) {
|
||||||
try {
|
try {
|
||||||
const res = await downloadScript(name);
|
const blob = await downloadScript(name); // Blob
|
||||||
// Expecting server returns { filename?: string, content?: string/base64 } or actual text
|
|
||||||
// If server returns raw script text, just download it as blob
|
|
||||||
let blob: Blob;
|
|
||||||
if (typeof res === 'string') {
|
|
||||||
blob = new Blob([res], { type: 'text/x-python' });
|
|
||||||
} else if (res && res.content && res.filename) {
|
|
||||||
// maybe base64
|
|
||||||
try {
|
|
||||||
const b = atob(res.content);
|
|
||||||
const arr = new Uint8Array(b.length);
|
|
||||||
for (let i = 0; i < b.length; i++) arr[i] = b.charCodeAt(i);
|
|
||||||
blob = new Blob([arr], { type: 'text/x-python' });
|
|
||||||
} catch {
|
|
||||||
blob = new Blob([res.content], { type: 'text/x-python' });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// fallback: JSON
|
|
||||||
blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
@@ -121,14 +170,49 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
|
|
||||||
async function handleDelete(name: string) {
|
async function handleDelete(name: string) {
|
||||||
try {
|
try {
|
||||||
await deleteScript(name);
|
const res: DeleteResult = await deleteScript(name);
|
||||||
notification.success({ message: 'Deleted', description: `${name} removed` });
|
notification.success({ message: 'Deleted', description: `${name} removed` });
|
||||||
|
if (res.errors && res.errors.length > 0) {
|
||||||
|
Modal.info({
|
||||||
|
title: 'Delete completed with errors',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<Paragraph>Errors occurred while deleting resources:</Paragraph>
|
||||||
|
<pre style={{ maxHeight: 240, overflow: 'auto' }}>{res.errors.join('\n')}</pre>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
refreshList();
|
refreshList();
|
||||||
|
refreshStatuses();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
notification.error({ message: 'Delete failed', description: err?.message || String(err) });
|
notification.error({ message: 'Delete failed', description: err?.message || String(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDisableInstance(scriptName: string, mapping: UnitMapping) {
|
||||||
|
const qnum = mapping.parsed.qnum;
|
||||||
|
if (typeof qnum !== 'number') {
|
||||||
|
notification.warning({ message: 'Cannot disable instance', description: 'qnum not available for this unit' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Disable ${mapping.service}?`,
|
||||||
|
content: `This will attempt to stop and remove unit ${mapping.service} (qnum ${qnum}).`,
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const res: OperationResult = await disableScript(scriptName, qnum);
|
||||||
|
notification.success({ message: 'Disabled', description: `${mapping.service} disabled` });
|
||||||
|
// refresh both lists and statuses
|
||||||
|
await refreshStatuses();
|
||||||
|
await refreshList();
|
||||||
|
} catch (err: any) {
|
||||||
|
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function fileToBlob(file: File | Blob) {
|
function fileToBlob(file: File | Blob) {
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
@@ -148,16 +232,24 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
const req = requirementsFile?.file || null;
|
const req = requirementsFile?.file || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await uploadScript({
|
const resp: ScriptUploadResponse = await uploadScript({
|
||||||
name,
|
name,
|
||||||
script,
|
script,
|
||||||
requirements: req,
|
requirements: req,
|
||||||
});
|
});
|
||||||
|
|
||||||
notification.success({ message: 'Uploaded', description: `${name} uploaded` });
|
notification.success({ message: 'Uploaded', description: `${name} uploaded` });
|
||||||
|
|
||||||
|
if (resp.pip && (resp.pip.stdout || resp.pip.stderr)) {
|
||||||
|
setPipOutput(resp.pip);
|
||||||
|
setPipModalVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
setUploadModalVisible(false);
|
setUploadModalVisible(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setCurrentEditingName(null);
|
setCurrentEditingName(null);
|
||||||
refreshList();
|
refreshList();
|
||||||
|
refreshStatuses();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
notification.error({ message: 'Upload failed', description: err?.message || String(err) });
|
notification.error({ message: 'Upload failed', description: err?.message || String(err) });
|
||||||
}
|
}
|
||||||
@@ -165,19 +257,8 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
|
|
||||||
async function openInEditorFromServer(name: string) {
|
async function openInEditorFromServer(name: string) {
|
||||||
try {
|
try {
|
||||||
const res = await downloadScript(name);
|
const blob = await downloadScript(name); // Blob
|
||||||
let text = '';
|
const text = await blob.text();
|
||||||
if (typeof res === 'string') text = res;
|
|
||||||
else if (res && res.content) {
|
|
||||||
// maybe base64
|
|
||||||
try {
|
|
||||||
text = atob(res.content);
|
|
||||||
} catch {
|
|
||||||
text = typeof res.content === 'string' ? res.content : JSON.stringify(res.content);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
text = JSON.stringify(res);
|
|
||||||
}
|
|
||||||
setEditorValue(text);
|
setEditorValue(text);
|
||||||
setEditorModalVisible(true);
|
setEditorModalVisible(true);
|
||||||
setCurrentEditingName(name);
|
setCurrentEditingName(name);
|
||||||
@@ -189,14 +270,20 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
|
|
||||||
async function handleSaveFromEditorAs(name: string) {
|
async function handleSaveFromEditorAs(name: string) {
|
||||||
try {
|
try {
|
||||||
// create blob from editor and upload
|
const resp: ScriptUploadResponse = await uploadScript({
|
||||||
await uploadScript({
|
|
||||||
name,
|
name,
|
||||||
script: new Blob([editorValue], { type: 'text/x-python' }),
|
script: new Blob([editorValue], { type: 'text/x-python' }),
|
||||||
});
|
});
|
||||||
notification.success({ message: 'Saved', description: `${name} uploaded` });
|
notification.success({ message: 'Saved', description: `${name} uploaded` });
|
||||||
|
|
||||||
|
if (resp.pip && (resp.pip.stdout || resp.pip.stderr)) {
|
||||||
|
setPipOutput(resp.pip);
|
||||||
|
setPipModalVisible(true);
|
||||||
|
}
|
||||||
|
|
||||||
setEditorModalVisible(false);
|
setEditorModalVisible(false);
|
||||||
refreshList();
|
refreshList();
|
||||||
|
refreshStatuses();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
notification.error({ message: 'Save failed', description: err?.message || String(err) });
|
notification.error({ message: 'Save failed', description: err?.message || String(err) });
|
||||||
}
|
}
|
||||||
@@ -205,7 +292,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
// enable modal/form
|
// enable modal/form
|
||||||
const [enableModalVisible, setEnableModalVisible] = useState(false);
|
const [enableModalVisible, setEnableModalVisible] = useState(false);
|
||||||
const [enableTarget, setEnableTarget] = useState<string | null>(null);
|
const [enableTarget, setEnableTarget] = useState<string | null>(null);
|
||||||
const [enableForm] = Form.useForm();
|
|
||||||
|
|
||||||
async function handleEnableSubmit(values: any) {
|
async function handleEnableSubmit(values: any) {
|
||||||
if (!enableTarget) return;
|
if (!enableTarget) return;
|
||||||
@@ -216,12 +302,13 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
enable_at_boot: !!values.enable_at_boot,
|
enable_at_boot: !!values.enable_at_boot,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await enableScript(enableTarget, req);
|
const res: OperationResult = await enableScript(enableTarget, req);
|
||||||
notification.success({ message: 'Enabled', description: `${enableTarget} enabled` });
|
notification.success({ message: 'Enabled', description: `${enableTarget} enabled as ${res.service}` });
|
||||||
setEnableModalVisible(false);
|
setEnableModalVisible(false);
|
||||||
setEnableTarget(null);
|
setEnableTarget(null);
|
||||||
enableForm.resetFields();
|
enableForm.resetFields();
|
||||||
refreshList();
|
refreshList();
|
||||||
|
refreshStatuses();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
notification.error({ message: 'Enable failed', description: err?.message || String(err) });
|
notification.error({ message: 'Enable failed', description: err?.message || String(err) });
|
||||||
}
|
}
|
||||||
@@ -229,16 +316,18 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
|
|
||||||
async function handleDisable(name: string) {
|
async function handleDisable(name: string) {
|
||||||
try {
|
try {
|
||||||
// ask for qnum? assume qnum required; if you know qnum mapping show UI to input; here we default to 0 if not known
|
// if you prefer, open a modal to ask qnum — keeping default 0 for backward compatibility
|
||||||
const qnum = 0;
|
const qnum = 0;
|
||||||
await disableScript(name, qnum);
|
const res: OperationResult = await disableScript(name, qnum);
|
||||||
notification.success({ message: 'Disabled', description: `${name} disabled` });
|
notification.success({ message: 'Disabled', description: `${name} disabled (service ${res.service})` });
|
||||||
refreshList();
|
refreshList();
|
||||||
|
refreshStatuses();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
|
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Table columns for main script list
|
||||||
const columns: ColumnsType<ScriptInfo> = [
|
const columns: ColumnsType<ScriptInfo> = [
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: 'Name',
|
||||||
@@ -255,6 +344,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
{
|
{
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
width: 320,
|
||||||
render: (_, record) => (
|
render: (_, record) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title="Download">
|
<Tooltip title="Download">
|
||||||
@@ -272,7 +362,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Disable">
|
<Tooltip title="Disable (defaults to qnum=0)">
|
||||||
<Button icon={<PauseCircleOutlined />} onClick={() => handleDisable(record.name)} />
|
<Button icon={<PauseCircleOutlined />} onClick={() => handleDisable(record.name)} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Popconfirm title={`Delete ${record.name}?`} onConfirm={() => handleDelete(record.name)}>
|
<Popconfirm title={`Delete ${record.name}?`} onConfirm={() => handleDelete(record.name)}>
|
||||||
@@ -283,6 +373,67 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// nested columns for unit mappings when a script row is expanded
|
||||||
|
const nestedColumns = [
|
||||||
|
{
|
||||||
|
title: 'Service',
|
||||||
|
dataIndex: ['parsed', 'service'],
|
||||||
|
key: 'service',
|
||||||
|
render: (_: any, r: UnitMapping) => <code>{r.service}</code>,
|
||||||
|
},
|
||||||
|
{ title: 'qnum', dataIndex: ['parsed', 'qnum'], key: 'qnum', render: (q: number | null) => (q == null ? '-' : q) },
|
||||||
|
{ title: 'Active', dataIndex: 'active', key: 'active', render: (a: boolean) => (a ? 'yes' : 'no') },
|
||||||
|
{ title: 'Extra', dataIndex: ['parsed', 'extra'], key: 'extra', render: (e: string | null) => e || '-' },
|
||||||
|
{
|
||||||
|
title: 'ExecStart',
|
||||||
|
dataIndex: ['parsed', 'exec_start'],
|
||||||
|
key: 'exec_start',
|
||||||
|
render: (s: string | null) => s || '-',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
key: 'unit_actions',
|
||||||
|
render: (_: any, r: UnitMapping) => (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleDisableInstance(r.parsed.name ?? r.parsed.service ?? '', r)}
|
||||||
|
danger
|
||||||
|
icon={<PauseCircleOutlined />}
|
||||||
|
disabled={typeof r.parsed.qnum !== 'number'}
|
||||||
|
>
|
||||||
|
Disable instance
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Table expandable config
|
||||||
|
const expandable = {
|
||||||
|
expandedRowRender: (record: ScriptInfo) => {
|
||||||
|
const mappings = getMappingsForScript(record.name);
|
||||||
|
if (!mappings || mappings.length === 0) {
|
||||||
|
return <div style={{ padding: 12 }}>No running instances for this script.</div>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
columns={nestedColumns}
|
||||||
|
dataSource={mappings}
|
||||||
|
pagination={false}
|
||||||
|
rowKey={(r: UnitMapping) => r.service}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
rowExpandable: (record: ScriptInfo) => {
|
||||||
|
const mappings = getMappingsForScript(record.name);
|
||||||
|
return mappings.length > 0;
|
||||||
|
},
|
||||||
|
defaultExpandAllRows: true,
|
||||||
|
// show expand icon in first column
|
||||||
|
expandRowByClick: false,
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Row justify="space-between" style={{ marginBottom: 12 }}>
|
<Row justify="space-between" style={{ marginBottom: 12 }}>
|
||||||
@@ -293,11 +444,32 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button onClick={refreshList}>Refresh</Button>
|
<Button onClick={refreshList}>Refresh</Button>
|
||||||
<Button onClick={refreshStatuses}>{fetchingStatuses ? <Spin size="small" /> : 'Statuses'}</Button>
|
<Button onClick={refreshStatuses}>{fetchingStatuses ? <Spin size="small" /> : 'Statuses'}</Button>
|
||||||
|
{statuses && (
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
Modal.info({
|
||||||
|
title: 'Status summary',
|
||||||
|
content: (
|
||||||
|
<pre style={{ maxHeight: 300, overflow: 'auto' }}>{JSON.stringify(statuses, null, 2)}</pre>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
View Raw Status
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Table rowKey="name" columns={columns} dataSource={scripts} loading={loading} pagination={{ pageSize: 10 }} />
|
<Table
|
||||||
|
rowKey="name"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={scripts}
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 10 }}
|
||||||
|
expandable={expandable}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Upload Modal */}
|
{/* Upload Modal */}
|
||||||
<Modal
|
<Modal
|
||||||
@@ -364,7 +536,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
<Button
|
<Button
|
||||||
type="default"
|
type="default"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// Save as new name
|
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: 'Save as',
|
title: 'Save as',
|
||||||
content: (
|
content: (
|
||||||
@@ -396,7 +567,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// overwrite current
|
|
||||||
if (!currentEditingName) {
|
if (!currentEditingName) {
|
||||||
notification.warning({ message: 'No filename to overwrite' });
|
notification.warning({ message: 'No filename to overwrite' });
|
||||||
return;
|
return;
|
||||||
@@ -453,6 +623,25 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Pip output modal */}
|
||||||
|
<Modal
|
||||||
|
open={pipModalVisible}
|
||||||
|
title="pip install output"
|
||||||
|
onCancel={() => {
|
||||||
|
setPipModalVisible(false);
|
||||||
|
setPipOutput(null);
|
||||||
|
}}
|
||||||
|
footer={<Button onClick={() => setPipModalVisible(false)}>Close</Button>}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||||
|
<h4>STDOUT</h4>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap' }}>{pipOutput?.stdout ?? '(none)'}</pre>
|
||||||
|
<h4>STDERR</h4>
|
||||||
|
<pre style={{ whiteSpace: 'pre-wrap', color: 'red' }}>{pipOutput?.stderr ?? '(none)'}</pre>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,55 @@ export type ScriptInfo = {
|
|||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ScriptUploadResponse = ScriptInfo & {
|
||||||
|
pip?: {
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnitParsed = {
|
||||||
|
service: string;
|
||||||
|
exec_start?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
script_path?: string | null;
|
||||||
|
qnum?: number | null;
|
||||||
|
extra?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnitStatus = {
|
||||||
|
parsed: UnitParsed;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnitMapping = {
|
||||||
|
service: string;
|
||||||
|
parsed: UnitParsed;
|
||||||
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StatusForNameResponse = {
|
||||||
|
name: string;
|
||||||
|
mappings: UnitMapping[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OperationResult = {
|
||||||
|
status: string;
|
||||||
|
name: string;
|
||||||
|
qnum?: number | null;
|
||||||
|
service?: string | null;
|
||||||
|
python?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DeleteResult = {
|
||||||
|
name: string;
|
||||||
|
units_removed: string[];
|
||||||
|
units_failed: string[];
|
||||||
|
files_removed: string[];
|
||||||
|
files_failed: string[];
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type EnableRequest = {
|
export type EnableRequest = {
|
||||||
qnum: number;
|
qnum: number;
|
||||||
service_name?: string | null;
|
service_name?: string | null;
|
||||||
|
|||||||
Reference in New Issue
Block a user