test scripting changes
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s

This commit is contained in:
2026-03-01 01:21:27 +01:00
parent 8fe9a06bfa
commit caf6799dd2
5 changed files with 474 additions and 158 deletions

View File

@@ -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:
@@ -684,4 +729,4 @@ def register_lifecycle(app):
remove_unit(svc) remove_unit(svc)
except Exception: except Exception:
logger.exception("Failed to remove unit %s on shutdown", svc) logger.exception("Failed to remove unit %s on shutdown", svc)
logger.info("Shutdown cleanup complete") logger.info("Shutdown cleanup complete")

View File

@@ -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;

View File

@@ -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}

View File

@@ -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>
); );
} }

View File

@@ -3,9 +3,58 @@ 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;
extra_args?: string | null; extra_args?: string | null;
enable_at_boot?: boolean | null; enable_at_boot?: boolean | null;
}; };