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 time
|
||||
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.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ---------- Configuration ----------
|
||||
SCRIPT_DIR = "/srv/fw-scripts"
|
||||
@@ -44,6 +44,7 @@ router = APIRouter(prefix="/scripts", tags=["scripts"])
|
||||
# ---------- Name validation ----------
|
||||
_NAME_RE = re.compile(r'^[A-Za-z0-9_.-]+$')
|
||||
|
||||
|
||||
def validate_name(name: str) -> None:
|
||||
if not name:
|
||||
raise ValueError("name must be provided")
|
||||
@@ -52,35 +53,43 @@ def validate_name(name: str) -> None:
|
||||
if name in (".", ".."):
|
||||
raise ValueError("invalid name")
|
||||
|
||||
|
||||
# ---------- Paths ----------
|
||||
def script_path_for(name: str) -> str:
|
||||
return os.path.join(SCRIPT_DIR, f"{name}.py")
|
||||
|
||||
|
||||
def requirements_path_for(name: str) -> str:
|
||||
return os.path.join(SCRIPT_DIR, f"{name}-requirements.txt")
|
||||
|
||||
|
||||
def venv_path_for(name: str) -> str:
|
||||
return os.path.join(VENV_BASE, name)
|
||||
|
||||
|
||||
def venv_python_for(name: str) -> str:
|
||||
vpy = os.path.join(venv_path_for(name), "bin", "python")
|
||||
if os.path.exists(vpy):
|
||||
return vpy
|
||||
return "/usr/bin/python3"
|
||||
|
||||
|
||||
def make_service_name(name: str, qnum: int) -> str:
|
||||
# safe, deterministic service name
|
||||
return f"{UNIT_PREFIX}-{name}-q{qnum}"
|
||||
|
||||
|
||||
def unit_path_for_name(service_name: str) -> str:
|
||||
# default location for units we write
|
||||
return os.path.join(UNIT_DIR, service_name + ".service")
|
||||
|
||||
|
||||
# ---------- systemd interaction (systemctl-based, no fallback) ----------
|
||||
def _systemctl_unit_name(unit: str) -> str:
|
||||
"""Return unit with .service suffix if missing."""
|
||||
return unit if unit.endswith(".service") else unit + ".service"
|
||||
|
||||
|
||||
def list_fw_units() -> List[str]:
|
||||
"""
|
||||
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] = []
|
||||
try:
|
||||
p = subprocess.run(["systemctl", "list-units", "--type=service", "--all", "--no-legend"],
|
||||
capture_output=True, text=True, check=False, timeout=3)
|
||||
p = subprocess.run(
|
||||
["systemctl", "list-units", "--type=service", "--all", "--no-legend"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=3,
|
||||
)
|
||||
out = p.stdout or ""
|
||||
# each line starts with unit name
|
||||
for line in out.splitlines():
|
||||
@@ -104,6 +118,7 @@ def list_fw_units() -> List[str]:
|
||||
logger.exception("systemctl list-units failed")
|
||||
return units
|
||||
|
||||
|
||||
def get_unit_fragment_path(service_name: str) -> Optional[str]:
|
||||
"""
|
||||
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)
|
||||
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.
|
||||
- reconstructs argv[] entries if systemd returns a structured blob
|
||||
- finds /srv/fw-scripts/<name>.py anywhere in the commandline
|
||||
- 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"
|
||||
|
||||
@@ -142,31 +172,17 @@ def parse_unit_execstart(service_name: str) -> Dict:
|
||||
raw = (p.stdout or "").strip()
|
||||
except Exception:
|
||||
logger.exception("systemctl show ExecStart failed for %s", service_name)
|
||||
return {
|
||||
"service": service_name,
|
||||
"exec_start": None,
|
||||
"name": None,
|
||||
"script_path": None,
|
||||
"qnum": None,
|
||||
"extra": None,
|
||||
}
|
||||
return UnitParsed(service=service_name)
|
||||
|
||||
if not raw:
|
||||
return {
|
||||
"service": service_name,
|
||||
"exec_start": None,
|
||||
"name": None,
|
||||
"script_path": None,
|
||||
"qnum": None,
|
||||
"extra": None,
|
||||
}
|
||||
return UnitParsed(service=service_name)
|
||||
|
||||
exec_start = raw
|
||||
exec_start: str = raw
|
||||
|
||||
# If structured with argv[] entries, extract them (allow newlines).
|
||||
if "argv[]=" in raw:
|
||||
argv_entries = re.findall(r'argv\[\]=([^;]+)', raw, flags=re.DOTALL)
|
||||
argv_parts = []
|
||||
argv_parts: List[str] = []
|
||||
for a in argv_entries:
|
||||
v = a.strip()
|
||||
# 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)
|
||||
if not m:
|
||||
# return raw exec_start but no parsed metadata
|
||||
return {
|
||||
"service": service_name,
|
||||
"exec_start": exec_start,
|
||||
"name": None,
|
||||
"script_path": None,
|
||||
"qnum": None,
|
||||
"extra": None,
|
||||
}
|
||||
return UnitParsed(service=service_name, exec_start=exec_start)
|
||||
|
||||
script_path = m.group(1)
|
||||
name = m.group("name")
|
||||
|
||||
# Find first integer token after the script path (queue number)
|
||||
after = exec_start[m.end():].strip()
|
||||
qnum = None
|
||||
extra = None
|
||||
qnum: Optional[int] = None
|
||||
extra: Optional[str] = None
|
||||
if after:
|
||||
tokens = after.split()
|
||||
for i, t in enumerate(tokens):
|
||||
@@ -204,19 +213,12 @@ def parse_unit_execstart(service_name: str) -> Dict:
|
||||
val = int(t)
|
||||
if 0 <= val <= 65535:
|
||||
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
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return {
|
||||
"service": service_name,
|
||||
"exec_start": exec_start,
|
||||
"name": name,
|
||||
"script_path": script_path,
|
||||
"qnum": qnum,
|
||||
"extra": extra,
|
||||
}
|
||||
return UnitParsed(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:
|
||||
@@ -224,9 +226,11 @@ def is_unit_active(service_name: str) -> bool:
|
||||
p = subprocess.run(["systemctl", "is-active", "--quiet", unit])
|
||||
return p.returncode == 0
|
||||
|
||||
|
||||
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.
|
||||
Returns the path to the unit file written.
|
||||
"""
|
||||
unit_path = unit_path_for_name(service_name)
|
||||
unit_text = f"""[Unit]
|
||||
@@ -256,18 +260,22 @@ WantedBy=multi-user.target
|
||||
logger.warning("Failed to enable %s at boot", service_name)
|
||||
return unit_path
|
||||
|
||||
|
||||
def start_unit(service_name: str) -> None:
|
||||
subprocess.run(["systemctl", "start", _systemctl_unit_name(service_name)], check=True)
|
||||
logger.info("Started service %s", service_name)
|
||||
|
||||
|
||||
def stop_unit(service_name: str) -> None:
|
||||
subprocess.run(["systemctl", "stop", _systemctl_unit_name(service_name)], check=True)
|
||||
logger.info("Stopped service %s", service_name)
|
||||
|
||||
|
||||
def disable_unit(service_name: str) -> None:
|
||||
subprocess.run(["systemctl", "disable", _systemctl_unit_name(service_name)], check=False)
|
||||
logger.info("Disabled service %s", service_name)
|
||||
|
||||
|
||||
def remove_unit(service_name: str) -> None:
|
||||
"""
|
||||
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:
|
||||
logger.exception("daemon-reload failed after removing unit %s", service_name)
|
||||
|
||||
|
||||
# ---------- venv + pip helpers ----------
|
||||
def create_venv(name: str, timeout: int = 60) -> str:
|
||||
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")
|
||||
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]:
|
||||
venv_dir = create_venv(name)
|
||||
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)
|
||||
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 ----------
|
||||
class ScriptInfo(BaseModel):
|
||||
name: str
|
||||
path: str
|
||||
|
||||
|
||||
class ScriptUploadResponse(ScriptInfo):
|
||||
pip: Optional[Dict[str, str]] = None
|
||||
|
||||
|
||||
class EnableRequest(BaseModel):
|
||||
qnum: int
|
||||
qnum: int = Field(..., description="Queue number (integer)")
|
||||
service_name: Optional[str] = None
|
||||
extra_args: Optional[str] = None
|
||||
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 ----------
|
||||
# Note: Place status endpoints before the dynamic GET /{name} route to avoid routing conflicts.
|
||||
|
||||
@router.get("/status")
|
||||
def status_all():
|
||||
@router.get("/status", response_model=Dict[str, UnitStatus])
|
||||
def status_all() -> Dict[str, UnitStatus]:
|
||||
"""
|
||||
Discover all fw-script units via systemctl and report parsed ExecStart + active state.
|
||||
"""
|
||||
units = list_fw_units()
|
||||
results: Dict[str, Dict] = {}
|
||||
results: Dict[str, UnitStatus] = {}
|
||||
for svc in units:
|
||||
parsed = parse_unit_execstart(svc)
|
||||
try:
|
||||
active = is_unit_active(svc)
|
||||
except Exception:
|
||||
active = False
|
||||
results[svc] = {"parsed": parsed, "active": active}
|
||||
results[svc] = UnitStatus(parsed=parsed, active=active)
|
||||
logger.debug("Status queried: found %d units", len(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:
|
||||
validate_name(name)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
units = list_fw_units()
|
||||
matches = []
|
||||
matches: List[UnitMapping] = []
|
||||
prefix = f"{UNIT_PREFIX}-{name}-q"
|
||||
for svc in units:
|
||||
if svc.startswith(prefix):
|
||||
@@ -393,16 +431,17 @@ def status_for_name(name: str):
|
||||
active = is_unit_active(svc)
|
||||
except Exception:
|
||||
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))
|
||||
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(
|
||||
script: UploadFile = File(...),
|
||||
name: str = Form(...),
|
||||
requirements: Optional[UploadFile] = File(None),
|
||||
):
|
||||
) -> ScriptUploadResponse:
|
||||
"""
|
||||
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.
|
||||
@@ -433,8 +472,8 @@ async def upload_script(
|
||||
logger.exception("Failed to write script file for %s: %s", name, e)
|
||||
raise HTTPException(status_code=500, detail="failed to save script")
|
||||
|
||||
req_path = None
|
||||
pip_output = None
|
||||
req_path: Optional[str] = None
|
||||
pip_output: Optional[Dict[str, str]] = None
|
||||
venv_created = False
|
||||
|
||||
try:
|
||||
@@ -488,19 +527,21 @@ async def upload_script(
|
||||
resp = {"name": name, "path": spath}
|
||||
if pip_output is not None:
|
||||
resp["pip"] = pip_output
|
||||
return resp
|
||||
return ScriptUploadResponse(**resp)
|
||||
|
||||
|
||||
@router.get("", response_model=List[ScriptInfo])
|
||||
def list_scripts():
|
||||
out = []
|
||||
def list_scripts() -> List[ScriptInfo]:
|
||||
out: List[ScriptInfo] = []
|
||||
for fn in os.listdir(SCRIPT_DIR):
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
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))
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/{name}")
|
||||
def download_script(name: str):
|
||||
# dynamic route - placed after /status and /{name}/status
|
||||
@@ -515,8 +556,9 @@ def download_script(name: str):
|
||||
logger.info("Download script %s", name)
|
||||
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:
|
||||
validate_name(name)
|
||||
except ValueError as e:
|
||||
@@ -534,7 +576,7 @@ def enable_script(name: str, req: EnableRequest):
|
||||
exec_start += " " + req.extra_args
|
||||
|
||||
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)
|
||||
start_unit(service_name)
|
||||
except subprocess.CalledProcessError as e:
|
||||
@@ -553,10 +595,11 @@ def enable_script(name: str, req: EnableRequest):
|
||||
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)
|
||||
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:
|
||||
validate_name(name)
|
||||
except ValueError as e:
|
||||
@@ -576,10 +619,11 @@ def disable_script(name: str, qnum: int):
|
||||
except Exception as e:
|
||||
logger.exception("Failed to disable unit %s: %s", service_name, 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.
|
||||
- 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)
|
||||
rpath = requirements_path_for(name)
|
||||
vpath = venv_path_for(name)
|
||||
file_removed = []
|
||||
file_failed = []
|
||||
file_removed: List[str] = []
|
||||
file_failed: List[str] = []
|
||||
|
||||
try:
|
||||
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)
|
||||
errors.append(f"remove_venv {vpath}: {e}")
|
||||
|
||||
result = {
|
||||
result: Dict[str, Any] = {
|
||||
"name": name,
|
||||
"units_removed": removed_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))
|
||||
return result
|
||||
|
||||
|
||||
# ---------- Lifecycle helper ----------
|
||||
def register_lifecycle(app):
|
||||
def register_lifecycle(app) -> None:
|
||||
@app.on_event("shutdown")
|
||||
def _shutdown_event():
|
||||
def _shutdown_event() -> None:
|
||||
logger.info("Shutdown: stopping/removing manager-created units with prefix %s", UNIT_PREFIX)
|
||||
units = list_fw_units()
|
||||
for svc in units:
|
||||
@@ -684,4 +729,4 @@ def register_lifecycle(app):
|
||||
remove_unit(svc)
|
||||
except Exception:
|
||||
logger.exception("Failed to remove unit %s on shutdown", svc)
|
||||
logger.info("Shutdown cleanup complete")
|
||||
logger.info("Shutdown cleanup complete")
|
||||
@@ -9,7 +9,15 @@ import {
|
||||
InterfaceInfo,
|
||||
RouteInfo,
|
||||
} 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';
|
||||
|
||||
const BASE = 'http://mitm.lan/api';
|
||||
@@ -17,7 +25,7 @@ const BASE = 'http://mitm.lan/api';
|
||||
export const api = axios.create({
|
||||
baseURL: BASE,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 10000,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
// Normalize FastAPI errors here
|
||||
@@ -160,13 +168,13 @@ export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
|
||||
Scripts
|
||||
------------------------- */
|
||||
|
||||
export const fetchScriptsStatusAll = async (): Promise<any> => {
|
||||
const res = await api.get('/scripts/scripts/status');
|
||||
export const fetchScriptsStatusAll = async (): Promise<Record<string, UnitStatus>> => {
|
||||
const res = await api.get<Record<string, UnitStatus>>('/scripts/scripts/status');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchScriptStatusForName = async (name: string): Promise<any> => {
|
||||
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/status`);
|
||||
export const fetchScriptStatusForName = async (name: string): Promise<StatusForNameResponse> => {
|
||||
const res = await api.get<StatusForNameResponse>(`/scripts/scripts/${encodeURIComponent(name)}/status`);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
@@ -179,38 +187,40 @@ export const uploadScript = async (opts: {
|
||||
name: string;
|
||||
script: File | Blob;
|
||||
requirements?: File | Blob | null;
|
||||
}): Promise<ScriptInfo> => {
|
||||
}): Promise<ScriptUploadResponse> => {
|
||||
const fd = new FormData();
|
||||
fd.append('name', opts.name);
|
||||
fd.append('script', opts.script);
|
||||
if (opts.requirements) fd.append('requirements', opts.requirements as Blob);
|
||||
|
||||
// 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' },
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const downloadScript = async (name: string): Promise<any> => {
|
||||
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`);
|
||||
return res.data;
|
||||
export const downloadScript = async (name: string): Promise<Blob> => {
|
||||
// expect the backend to return a FileResponse. request as blob to trigger browser download handling.
|
||||
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> = {};
|
||||
if (typeof qnum !== 'undefined') params.qnum = qnum;
|
||||
const res = await api.delete(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
|
||||
if (typeof qnum !== 'undefined' && qnum !== null) params.qnum = qnum;
|
||||
const res = await api.delete<DeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const enableScript = async (name: string, req: EnableRequest): Promise<any> => {
|
||||
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
|
||||
export const enableScript = async (name: string, req: EnableRequest): Promise<OperationResult> => {
|
||||
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const disableScript = async (name: string, qnum: number): Promise<any> => {
|
||||
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
|
||||
export const disableScript = async (name: string, qnum: number): Promise<OperationResult> => {
|
||||
// backend expects qnum as query param (POST body null)
|
||||
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
|
||||
params: { qnum },
|
||||
});
|
||||
return res.data;
|
||||
|
||||
@@ -11,6 +11,7 @@ export type PythonEditorProps = {
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
height?: string | number; // e.g. '200px' or 200
|
||||
wrap?: boolean; // if true, long lines will wrap; default false (no wrap)
|
||||
};
|
||||
|
||||
export default function PythonEditor({
|
||||
@@ -19,6 +20,7 @@ export default function PythonEditor({
|
||||
readOnly = false,
|
||||
height = 480,
|
||||
className = '',
|
||||
wrap = false,
|
||||
}: PythonEditorProps) {
|
||||
const highlight = useCallback((code: string) => {
|
||||
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(
|
||||
() => ({
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
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 (
|
||||
<div className={`python-editor ${className}`}>
|
||||
<div className={`python-editor ${className}`} style={containerStyle}>
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={(code) => onChange(code)}
|
||||
highlight={highlight}
|
||||
padding={12}
|
||||
padding={0} // padding handled by editorStyle
|
||||
preClassName="language-python"
|
||||
textareaId="python-editor"
|
||||
style={editorStyle}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Spin,
|
||||
Table,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Upload,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
@@ -35,12 +36,36 @@ import {
|
||||
uploadScript,
|
||||
} from '../api/apiClient';
|
||||
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 = {
|
||||
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) {
|
||||
const [scripts, setScripts] = useState<ScriptInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -49,11 +74,19 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
const [editorValue, setEditorValue] = useState<string>('');
|
||||
const [currentEditingName, setCurrentEditingName] = useState<string | null>(null);
|
||||
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 [enableForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
refreshList();
|
||||
// optional: fetch statuses immediately as well
|
||||
refreshStatuses();
|
||||
}, []);
|
||||
|
||||
async function refreshList() {
|
||||
@@ -72,9 +105,9 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
setFetchingStatuses(true);
|
||||
try {
|
||||
const all = await fetchScriptsStatusAll();
|
||||
// assume the API returns keyed info matching script names. adapt as needed
|
||||
// For simplicity, re-fetch list (which may include status), but if you have separate structure, merge.
|
||||
await refreshList();
|
||||
setStatuses(all);
|
||||
const count = all ? Object.keys(all).length : 0;
|
||||
notification.info({ message: 'Statuses fetched', description: `Found ${count} service(s)` });
|
||||
} catch (err: any) {
|
||||
notification.error({ message: 'Failed to fetch statuses', description: err?.message || String(err) });
|
||||
} 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) {
|
||||
try {
|
||||
const res = await downloadScript(name);
|
||||
// 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 blob = await downloadScript(name); // Blob
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -121,14 +170,49 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
|
||||
async function handleDelete(name: string) {
|
||||
try {
|
||||
await deleteScript(name);
|
||||
const res: DeleteResult = await deleteScript(name);
|
||||
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();
|
||||
refreshStatuses();
|
||||
} catch (err: any) {
|
||||
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) {
|
||||
return file;
|
||||
}
|
||||
@@ -148,16 +232,24 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
const req = requirementsFile?.file || null;
|
||||
|
||||
try {
|
||||
await uploadScript({
|
||||
const resp: ScriptUploadResponse = await uploadScript({
|
||||
name,
|
||||
script,
|
||||
requirements: req,
|
||||
});
|
||||
|
||||
notification.success({ message: 'Uploaded', description: `${name} uploaded` });
|
||||
|
||||
if (resp.pip && (resp.pip.stdout || resp.pip.stderr)) {
|
||||
setPipOutput(resp.pip);
|
||||
setPipModalVisible(true);
|
||||
}
|
||||
|
||||
setUploadModalVisible(false);
|
||||
form.resetFields();
|
||||
setCurrentEditingName(null);
|
||||
refreshList();
|
||||
refreshStatuses();
|
||||
} catch (err: any) {
|
||||
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) {
|
||||
try {
|
||||
const res = await downloadScript(name);
|
||||
let 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);
|
||||
}
|
||||
const blob = await downloadScript(name); // Blob
|
||||
const text = await blob.text();
|
||||
setEditorValue(text);
|
||||
setEditorModalVisible(true);
|
||||
setCurrentEditingName(name);
|
||||
@@ -189,14 +270,20 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
|
||||
async function handleSaveFromEditorAs(name: string) {
|
||||
try {
|
||||
// create blob from editor and upload
|
||||
await uploadScript({
|
||||
const resp: ScriptUploadResponse = await uploadScript({
|
||||
name,
|
||||
script: new Blob([editorValue], { type: 'text/x-python' }),
|
||||
});
|
||||
notification.success({ message: 'Saved', description: `${name} uploaded` });
|
||||
|
||||
if (resp.pip && (resp.pip.stdout || resp.pip.stderr)) {
|
||||
setPipOutput(resp.pip);
|
||||
setPipModalVisible(true);
|
||||
}
|
||||
|
||||
setEditorModalVisible(false);
|
||||
refreshList();
|
||||
refreshStatuses();
|
||||
} catch (err: any) {
|
||||
notification.error({ message: 'Save failed', description: err?.message || String(err) });
|
||||
}
|
||||
@@ -205,7 +292,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
// enable modal/form
|
||||
const [enableModalVisible, setEnableModalVisible] = useState(false);
|
||||
const [enableTarget, setEnableTarget] = useState<string | null>(null);
|
||||
const [enableForm] = Form.useForm();
|
||||
|
||||
async function handleEnableSubmit(values: any) {
|
||||
if (!enableTarget) return;
|
||||
@@ -216,12 +302,13 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
enable_at_boot: !!values.enable_at_boot,
|
||||
};
|
||||
try {
|
||||
await enableScript(enableTarget, req);
|
||||
notification.success({ message: 'Enabled', description: `${enableTarget} enabled` });
|
||||
const res: OperationResult = await enableScript(enableTarget, req);
|
||||
notification.success({ message: 'Enabled', description: `${enableTarget} enabled as ${res.service}` });
|
||||
setEnableModalVisible(false);
|
||||
setEnableTarget(null);
|
||||
enableForm.resetFields();
|
||||
refreshList();
|
||||
refreshStatuses();
|
||||
} catch (err: any) {
|
||||
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) {
|
||||
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;
|
||||
await disableScript(name, qnum);
|
||||
notification.success({ message: 'Disabled', description: `${name} disabled` });
|
||||
const res: OperationResult = await disableScript(name, qnum);
|
||||
notification.success({ message: 'Disabled', description: `${name} disabled (service ${res.service})` });
|
||||
refreshList();
|
||||
refreshStatuses();
|
||||
} catch (err: any) {
|
||||
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
// Table columns for main script list
|
||||
const columns: ColumnsType<ScriptInfo> = [
|
||||
{
|
||||
title: 'Name',
|
||||
@@ -255,6 +344,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 320,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Tooltip title="Download">
|
||||
@@ -272,7 +362,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="Disable">
|
||||
<Tooltip title="Disable (defaults to qnum=0)">
|
||||
<Button icon={<PauseCircleOutlined />} onClick={() => handleDisable(record.name)} />
|
||||
</Tooltip>
|
||||
<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 (
|
||||
<div>
|
||||
<Row justify="space-between" style={{ marginBottom: 12 }}>
|
||||
@@ -293,11 +444,32 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
</Button>
|
||||
<Button onClick={refreshList}>Refresh</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>
|
||||
</Col>
|
||||
</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 */}
|
||||
<Modal
|
||||
@@ -364,7 +536,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
// Save as new name
|
||||
Modal.confirm({
|
||||
title: 'Save as',
|
||||
content: (
|
||||
@@ -396,7 +567,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
// overwrite current
|
||||
if (!currentEditingName) {
|
||||
notification.warning({ message: 'No filename to overwrite' });
|
||||
return;
|
||||
@@ -453,6 +623,25 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,58 @@ export type ScriptInfo = {
|
||||
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 = {
|
||||
qnum: number;
|
||||
service_name?: string | null;
|
||||
extra_args?: string | null;
|
||||
enable_at_boot?: boolean | null;
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user