script improvements
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:
@@ -1,14 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
NFQUEUE Python-Scripting API Router
|
NFQUEUE Python-Scripting API Router
|
||||||
Endpoints:
|
Endpoints:
|
||||||
- POST /scripts -> upload script (multipart): script file, optional requirements file, required 'name' form field
|
- POST /scripts -> upload script (multipart): script file, optional requirements file, required 'name' form field
|
||||||
- GET /scripts -> list scripts
|
- GET /scripts -> list scripts + per-script unit mappings/status (combined)
|
||||||
- GET /scripts/{name} -> download script (binary blob)
|
- GET /scripts/{name} -> download script (binary blob)
|
||||||
- POST /scripts/{name}/enable -> enable systemd service for script on given qnum
|
- GET /scripts/{name}/requirements -> download requirements file (binary blob) if present
|
||||||
- POST /scripts/{name}/disable -> disable service for script on qnum
|
- PUT /scripts/{name}/requirements -> upload/replace requirements file (multipart). ALWAYS runs pip install and returns pip output.
|
||||||
- DELETE /scripts/{name} -> disable all or a specific qnum service(s) and delete script + venv + requirements
|
- DELETE /scripts/{name}/requirements -> delete only requirements file and remove venv (cleanup)
|
||||||
- GET /scripts/status -> status of all fw-script units
|
- POST /scripts/{name}/enable -> enable systemd service for script on given qnum
|
||||||
- GET /scripts/{name}/status -> status of units for that script
|
- POST /scripts/{name}/disable -> disable service for script on qnum
|
||||||
|
- DELETE /scripts/{name} -> disable all or a specific qnum service(s) and delete script + venv + requirements
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -21,7 +22,7 @@ import logging
|
|||||||
from typing import Optional, List, Dict, Any
|
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, JSONResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
# ---------- Configuration ----------
|
# ---------- Configuration ----------
|
||||||
@@ -364,6 +365,22 @@ class ScriptInfo(BaseModel):
|
|||||||
path: str
|
path: str
|
||||||
|
|
||||||
|
|
||||||
|
class UnitMapping(BaseModel):
|
||||||
|
service: str
|
||||||
|
parsed: UnitParsed
|
||||||
|
active: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptWithStatus(ScriptInfo):
|
||||||
|
"""
|
||||||
|
Represents a script plus discovered unit mappings (if any).
|
||||||
|
- mappings: list of UnitMapping for that script
|
||||||
|
- requirements_exists: whether a requirements file exists on disk
|
||||||
|
"""
|
||||||
|
mappings: List[UnitMapping] = []
|
||||||
|
requirements_exists: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ScriptUploadResponse(ScriptInfo):
|
class ScriptUploadResponse(ScriptInfo):
|
||||||
pip: Optional[Dict[str, str]] = None
|
pip: Optional[Dict[str, str]] = None
|
||||||
|
|
||||||
@@ -375,12 +392,6 @@ class EnableRequest(BaseModel):
|
|||||||
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):
|
class StatusForNameResponse(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
mappings: List[UnitMapping]
|
mappings: List[UnitMapping]
|
||||||
@@ -395,45 +406,47 @@ class OperationResult(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# ---------- Endpoints ----------
|
# ---------- Endpoints ----------
|
||||||
# Note: Place status endpoints before the dynamic GET /{name} route to avoid routing conflicts.
|
# NOTE: combined status info into GET /scripts below (replaces separate /status & /{name}/status endpoints)
|
||||||
|
|
||||||
@router.get("/status", response_model=Dict[str, UnitStatus])
|
@router.get("", response_model=List[ScriptWithStatus])
|
||||||
def status_all() -> Dict[str, UnitStatus]:
|
def list_scripts_with_status() -> List[ScriptWithStatus]:
|
||||||
"""
|
"""
|
||||||
Discover all fw-script units via systemctl and report parsed ExecStart + active state.
|
Return list of scripts plus per-script unit mappings/status.
|
||||||
|
This combines the former /scripts and /scripts/status endpoints so clients get everything in one call.
|
||||||
|
Each entry contains:
|
||||||
|
- name, path
|
||||||
|
- mappings: list of UnitMapping (service, parsed ExecStart, active flag)
|
||||||
|
- requirements_exists: boolean
|
||||||
"""
|
"""
|
||||||
units = list_fw_units()
|
out: List[ScriptWithStatus] = []
|
||||||
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] = UnitStatus(parsed=parsed, active=active)
|
|
||||||
logger.debug("Status queried: found %d units", len(results))
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
# build a list of units once for efficiency
|
||||||
|
all_units = list_fw_units()
|
||||||
|
logger.debug("list_scripts_with_status: discovered %d fw units", len(all_units))
|
||||||
|
|
||||||
@router.get("/{name}/status", response_model=StatusForNameResponse)
|
for fn in os.listdir(SCRIPT_DIR):
|
||||||
def status_for_name(name: str) -> StatusForNameResponse:
|
if not fn.endswith(".py"):
|
||||||
try:
|
continue
|
||||||
validate_name(name)
|
name = fn.rsplit(".", 1)[0]
|
||||||
except ValueError as e:
|
spath = os.path.join(SCRIPT_DIR, fn)
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
|
||||||
units = list_fw_units()
|
# find units matching this script
|
||||||
matches: List[UnitMapping] = []
|
prefix = f"{UNIT_PREFIX}-{name}-q"
|
||||||
prefix = f"{UNIT_PREFIX}-{name}-q"
|
mappings: List[UnitMapping] = []
|
||||||
for svc in units:
|
for svc in all_units:
|
||||||
if svc.startswith(prefix):
|
if svc.startswith(prefix):
|
||||||
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
|
||||||
matches.append(UnitMapping(service=svc, parsed=parsed, active=active))
|
mappings.append(UnitMapping(service=svc, parsed=parsed, active=active))
|
||||||
logger.debug("Status for %s -> %d matches", name, len(matches))
|
|
||||||
return StatusForNameResponse(name=name, mappings=matches)
|
req_exists = os.path.exists(requirements_path_for(name))
|
||||||
|
out.append(ScriptWithStatus(name=name, path=spath, mappings=mappings, requirements_exists=req_exists))
|
||||||
|
|
||||||
|
logger.debug("Listed %d scripts with status", len(out))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ScriptUploadResponse)
|
@router.post("", response_model=ScriptUploadResponse)
|
||||||
@@ -455,6 +468,10 @@ async def upload_script(
|
|||||||
logger.warning("Invalid name provided: %s", name)
|
logger.warning("Invalid name provided: %s", name)
|
||||||
raise HTTPException(status_code=400, detail=str(e))
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
if not script.filename.endswith(".py"):
|
||||||
|
logger.warning("Upload rejected: script not .py (name=%s original=%s)", name, script.filename)
|
||||||
|
raise HTTPException(status_code=400, detail="only .py scripts allowed")
|
||||||
|
|
||||||
spath = script_path_for(name)
|
spath = script_path_for(name)
|
||||||
if os.path.exists(spath):
|
if os.path.exists(spath):
|
||||||
logger.warning("Upload rejected: script with name already exists: %s", name)
|
logger.warning("Upload rejected: script with name already exists: %s", name)
|
||||||
@@ -482,6 +499,7 @@ async def upload_script(
|
|||||||
fh.write(req_data)
|
fh.write(req_data)
|
||||||
logger.info("Saved requirements for %s at %s", name, req_path)
|
logger.info("Saved requirements for %s at %s", name, req_path)
|
||||||
try:
|
try:
|
||||||
|
# create venv (if needed) and run pip install; pip output is returned in response
|
||||||
create_venv(name)
|
create_venv(name)
|
||||||
venv_created = True
|
venv_created = True
|
||||||
res = pip_install_requirements(name, req_path)
|
res = pip_install_requirements(name, req_path)
|
||||||
@@ -500,6 +518,7 @@ async def upload_script(
|
|||||||
shutil.rmtree(venv_path_for(name), ignore_errors=True)
|
shutil.rmtree(venv_path_for(name), ignore_errors=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Cleanup after pip failure partially failed for %s", name)
|
logger.exception("Cleanup after pip failure partially failed for %s", name)
|
||||||
|
# Return pip failure as 500 with message
|
||||||
raise HTTPException(status_code=500, detail=f"pip install failed: {err_msg}")
|
raise HTTPException(status_code=500, detail=f"pip install failed: {err_msg}")
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
@@ -528,18 +547,6 @@ async def upload_script(
|
|||||||
return ScriptUploadResponse(**resp)
|
return ScriptUploadResponse(**resp)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=List[ScriptInfo])
|
|
||||||
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(ScriptInfo(name=name, path=os.path.join(SCRIPT_DIR, fn)))
|
|
||||||
logger.debug("Listed %d scripts", len(out))
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{name}",
|
"/{name}",
|
||||||
response_class=FileResponse,
|
response_class=FileResponse,
|
||||||
@@ -571,6 +578,140 @@ def download_script(name: str) -> FileResponse:
|
|||||||
return FileResponse(path, media_type="application/octet-stream", filename=f"{name}.py")
|
return FileResponse(path, media_type="application/octet-stream", filename=f"{name}.py")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{name}/requirements",
|
||||||
|
response_class=FileResponse,
|
||||||
|
responses={
|
||||||
|
200: {
|
||||||
|
"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}},
|
||||||
|
"description": "requirements.txt file (binary).",
|
||||||
|
},
|
||||||
|
404: {"description": "Not found"},
|
||||||
|
400: {"description": "Invalid name"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def download_requirements(name: str) -> FileResponse:
|
||||||
|
"""
|
||||||
|
Download the stored requirements file for a script as binary blob.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validate_name(name)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
path = requirements_path_for(name)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise HTTPException(status_code=404, detail="requirements not found")
|
||||||
|
logger.info("Download requirements for %s", name)
|
||||||
|
return FileResponse(path, media_type="application/octet-stream", filename=f"{name}-requirements.txt")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{name}/requirements",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Requirements replaced and pip output returned"},
|
||||||
|
400: {"description": "Invalid name or bad request"},
|
||||||
|
500: {"description": "pip install failed or storage error"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def upload_requirements_install(name: str, requirements: UploadFile = File(...)) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Replace / upload the requirements file for a given script.
|
||||||
|
- requirements: multipart file upload (UploadFile) — keeps Swagger UI file input.
|
||||||
|
- This endpoint ALWAYS runs pip install -r <file> into the script's venv and returns pip stdout/stderr.
|
||||||
|
If pip install fails, a 500 error is returned with the pip failure message.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validate_name(name)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
# ensure script exists (we don't allow attaching requirements to non-existing script)
|
||||||
|
spath = script_path_for(name)
|
||||||
|
if not os.path.exists(spath):
|
||||||
|
raise HTTPException(status_code=404, detail="script not found")
|
||||||
|
|
||||||
|
if not requirements:
|
||||||
|
raise HTTPException(status_code=400, detail="requirements file required")
|
||||||
|
|
||||||
|
req_path = requirements_path_for(name)
|
||||||
|
try:
|
||||||
|
data = await requirements.read()
|
||||||
|
with open(req_path, "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
logger.info("Saved requirements for %s at %s", name, req_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed to write requirements for %s: %s", name, e)
|
||||||
|
raise HTTPException(status_code=500, detail="failed to save requirements")
|
||||||
|
|
||||||
|
# Now ALWAYS install and return pip output (raise 500 on failure)
|
||||||
|
try:
|
||||||
|
create_venv(name)
|
||||||
|
res = pip_install_requirements(name, req_path)
|
||||||
|
logger.info("pip install completed for %s via requirements upload", name)
|
||||||
|
return {"pip": {"stdout": res.get("stdout", ""), "stderr": res.get("stderr", "")}}
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("pip install failed for %s: %s", name, e)
|
||||||
|
# keep the requirements file for inspection; return 500 with details
|
||||||
|
raise HTTPException(status_code=500, detail=f"pip install failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{name}/requirements",
|
||||||
|
responses={
|
||||||
|
200: {"description": "requirements removed and venv cleaned up"},
|
||||||
|
404: {"description": "script or requirements not found"},
|
||||||
|
500: {"description": "cleanup error"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
def delete_requirements_and_cleanup(name: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Delete only the requirements file for a script and attempt to remove the script's venv directory.
|
||||||
|
Returns a summary of what was removed and any errors.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validate_name(name)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
|
spath = script_path_for(name)
|
||||||
|
if not os.path.exists(spath):
|
||||||
|
raise HTTPException(status_code=404, detail="script not found")
|
||||||
|
|
||||||
|
rpath = requirements_path_for(name)
|
||||||
|
vpath = venv_path_for(name)
|
||||||
|
|
||||||
|
removed = {"requirements_removed": False, "venv_removed": False}
|
||||||
|
errors: List[str] = []
|
||||||
|
|
||||||
|
# remove requirements file
|
||||||
|
try:
|
||||||
|
if os.path.exists(rpath):
|
||||||
|
os.remove(rpath)
|
||||||
|
removed["requirements_removed"] = True
|
||||||
|
logger.info("Removed requirements file %s", rpath)
|
||||||
|
else:
|
||||||
|
logger.debug("No requirements file to remove for %s", name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed removing requirements file %s: %s", rpath, e)
|
||||||
|
errors.append(f"remove_requirements {rpath}: {e}")
|
||||||
|
|
||||||
|
# remove venv directory
|
||||||
|
try:
|
||||||
|
if os.path.isdir(vpath):
|
||||||
|
shutil.rmtree(vpath, ignore_errors=False)
|
||||||
|
removed["venv_removed"] = True
|
||||||
|
logger.info("Removed venv directory %s", vpath)
|
||||||
|
else:
|
||||||
|
logger.debug("No venv directory to remove for %s", name)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Failed removing venv %s: %s", vpath, e)
|
||||||
|
errors.append(f"remove_venv {vpath}: {e}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
return JSONResponse(status_code=500, content={"removed": removed, "errors": errors})
|
||||||
|
return {"removed": removed}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{name}/enable", response_model=OperationResult)
|
@router.post("/{name}/enable", response_model=OperationResult)
|
||||||
def enable_script(name: str, req: EnableRequest) -> OperationResult:
|
def enable_script(name: str, req: EnableRequest) -> OperationResult:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import {
|
|||||||
DeleteResult,
|
DeleteResult,
|
||||||
EnableRequest,
|
EnableRequest,
|
||||||
OperationResult,
|
OperationResult,
|
||||||
|
RequirementsDeleteResult,
|
||||||
|
RequirementsUploadResult,
|
||||||
ScriptInfo,
|
ScriptInfo,
|
||||||
ScriptUploadResponse,
|
ScriptUploadResponse,
|
||||||
StatusForNameResponse,
|
ScriptWithStatus,
|
||||||
UnitStatus,
|
StatusForNameResponse
|
||||||
} from '../types/scripting';
|
} from '../types/scripting';
|
||||||
import { SnifferStatusResponse } from '../types/sniffer';
|
import { SnifferStatusResponse } from '../types/sniffer';
|
||||||
|
|
||||||
@@ -168,21 +170,42 @@ export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
|
|||||||
Scripts
|
Scripts
|
||||||
------------------------- */
|
------------------------- */
|
||||||
|
|
||||||
export const fetchScriptsStatusAll = async (): Promise<Record<string, UnitStatus>> => {
|
/**
|
||||||
const res = await api.get<Record<string, UnitStatus>>('/scripts/scripts/status');
|
* Fetch the combined scripts + status endpoint.
|
||||||
return res.data;
|
* Returns a list of ScriptWithStatus entries.
|
||||||
};
|
*/
|
||||||
|
export const fetchScriptsAll = async (): Promise<ScriptWithStatus[]> => {
|
||||||
export const fetchScriptStatusForName = async (name: string): Promise<StatusForNameResponse> => {
|
const res = await api.get<ScriptWithStatus[]>('/scripts/scripts');
|
||||||
const res = await api.get<StatusForNameResponse>(`/scripts/scripts/${encodeURIComponent(name)}/status`);
|
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: list only the basic ScriptInfo items (no mappings).
|
||||||
|
* This uses the combined endpoint and maps to ScriptInfo[].
|
||||||
|
*/
|
||||||
export const listScripts = async (): Promise<ScriptInfo[]> => {
|
export const listScripts = async (): Promise<ScriptInfo[]> => {
|
||||||
const res = await api.get<ScriptInfo[]>('/scripts/scripts');
|
const all = await fetchScriptsAll();
|
||||||
return res.data;
|
return all.map((s) => ({ name: s.name, path: s.path }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get status/mappings for a single script by name.
|
||||||
|
* Because the backend merged status into /scripts, we fetch that and filter.
|
||||||
|
*/
|
||||||
|
export const fetchScriptStatusForName = async (name: string): Promise<StatusForNameResponse> => {
|
||||||
|
const all = await fetchScriptsAll();
|
||||||
|
const found = all.find((s) => s.name === name);
|
||||||
|
if (!found) {
|
||||||
|
// If script not found we still return an empty mapping structure
|
||||||
|
return { name, mappings: [] };
|
||||||
|
}
|
||||||
|
return { name: found.name, mappings: found.mappings || [] };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a script (multipart). If a requirements file is provided the backend
|
||||||
|
* will run pip install and return pip output in the response (mandatory install).
|
||||||
|
*/
|
||||||
export const uploadScript = async (opts: {
|
export const uploadScript = async (opts: {
|
||||||
name: string;
|
name: string;
|
||||||
script: File | Blob;
|
script: File | Blob;
|
||||||
@@ -193,19 +216,59 @@ export const uploadScript = async (opts: {
|
|||||||
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
|
|
||||||
const res = await api.post<ScriptUploadResponse>('/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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the script file as a Blob. Use this blob to create an object URL or read text.
|
||||||
|
*/
|
||||||
export const downloadScript = async (name: string): Promise<Blob> => {
|
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' });
|
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`, { responseType: 'blob' });
|
||||||
return res.data as Blob;
|
return res.data as Blob;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the requirements.txt for a script as a Blob.
|
||||||
|
* Returns 404 if not present (axios will throw).
|
||||||
|
*/
|
||||||
|
export const downloadRequirements = async (name: string): Promise<Blob> => {
|
||||||
|
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/requirements`, { responseType: 'blob' });
|
||||||
|
return res.data as Blob;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace / upload requirements for a script. This endpoint ALWAYS runs pip install
|
||||||
|
* and will return pip stdout/stderr on success. On pip failure the backend returns 500.
|
||||||
|
*
|
||||||
|
* Use multipart/form-data so Swagger UI shows a file picker (backend expects UploadFile).
|
||||||
|
*/
|
||||||
|
export const uploadRequirements = async (
|
||||||
|
name: string,
|
||||||
|
requirements: File | Blob,
|
||||||
|
): Promise<RequirementsUploadResult> => {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('requirements', requirements);
|
||||||
|
const res = await api.put<RequirementsUploadResult>(`/scripts/scripts/${encodeURIComponent(name)}/requirements`, fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete only the requirements file and attempt to clean up the venv.
|
||||||
|
* Returns summary `{ removed: { requirements_removed, venv_removed }, errors? }`.
|
||||||
|
*/
|
||||||
|
export const deleteRequirements = async (name: string): Promise<RequirementsDeleteResult> => {
|
||||||
|
const res = await api.delete<RequirementsDeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}/requirements`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a script (and its files / venv). If qnum provided only remove that unit.
|
||||||
|
*/
|
||||||
export const deleteScript = async (name: string, qnum?: number | null): Promise<DeleteResult> => {
|
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' && qnum !== null) params.qnum = qnum;
|
if (typeof qnum !== 'undefined' && qnum !== null) params.qnum = qnum;
|
||||||
@@ -213,13 +276,18 @@ export const deleteScript = async (name: string, qnum?: number | null): Promise<
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable a script (creates + starts a systemd unit). Returns OperationResult including service name.
|
||||||
|
*/
|
||||||
export const enableScript = async (name: string, req: EnableRequest): Promise<OperationResult> => {
|
export const enableScript = async (name: string, req: EnableRequest): Promise<OperationResult> => {
|
||||||
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
|
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a script unit (POST with qnum as query param). Returns OperationResult.
|
||||||
|
*/
|
||||||
export const disableScript = async (name: string, qnum: number): Promise<OperationResult> => {
|
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, {
|
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
|
||||||
params: { qnum },
|
params: { qnum },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,12 +27,15 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
deleteRequirements,
|
||||||
deleteScript,
|
deleteScript,
|
||||||
disableScript,
|
disableScript,
|
||||||
|
downloadRequirements,
|
||||||
downloadScript,
|
downloadScript,
|
||||||
enableScript,
|
enableScript,
|
||||||
fetchScriptsStatusAll,
|
fetchScriptsAll,
|
||||||
listScripts,
|
listScripts,
|
||||||
|
uploadRequirements,
|
||||||
uploadScript,
|
uploadScript,
|
||||||
} from '../api/apiClient';
|
} from '../api/apiClient';
|
||||||
import PythonEditor from '../components/PythonEditor';
|
import PythonEditor from '../components/PythonEditor';
|
||||||
@@ -42,7 +45,7 @@ import {
|
|||||||
OperationResult,
|
OperationResult,
|
||||||
ScriptInfo,
|
ScriptInfo,
|
||||||
ScriptUploadResponse,
|
ScriptUploadResponse,
|
||||||
UnitStatus,
|
ScriptWithStatus,
|
||||||
} from '../types/scripting';
|
} from '../types/scripting';
|
||||||
|
|
||||||
const { Paragraph } = Typography;
|
const { Paragraph } = Typography;
|
||||||
@@ -67,33 +70,53 @@ type UnitMapping = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ScriptsManager({ onOpenInEditor }: Props) {
|
export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||||
const [scripts, setScripts] = useState<ScriptInfo[]>([]);
|
// now scripts holds ScriptWithStatus entries (name,path,mappings,requirements_exists)
|
||||||
|
const [scripts, setScripts] = useState<ScriptWithStatus[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [uploadModalVisible, setUploadModalVisible] = useState(false);
|
const [uploadModalVisible, setUploadModalVisible] = useState(false);
|
||||||
const [editorModalVisible, setEditorModalVisible] = useState(false);
|
const [editorModalVisible, setEditorModalVisible] = useState(false);
|
||||||
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
|
// pip output modal
|
||||||
const [pipModalVisible, setPipModalVisible] = useState(false);
|
const [pipModalVisible, setPipModalVisible] = useState(false);
|
||||||
const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null);
|
const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null);
|
||||||
|
|
||||||
|
// requirements editor modal
|
||||||
|
const [reqModalVisible, setReqModalVisible] = useState(false);
|
||||||
|
const [reqEditorValue, setReqEditorValue] = useState<string>('');
|
||||||
|
const [reqEditingName, setReqEditingName] = useState<string | null>(null);
|
||||||
|
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [enableForm] = Form.useForm();
|
const [enableForm] = Form.useForm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshList();
|
refreshAll();
|
||||||
// optional: fetch statuses immediately as well
|
|
||||||
refreshStatuses();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const all = await fetchScriptsAll();
|
||||||
|
setScripts(all || []);
|
||||||
|
} catch (err: any) {
|
||||||
|
notification.error({ message: 'Failed to fetch scripts', description: err?.message || String(err) });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshList() {
|
async function refreshList() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await listScripts();
|
const data = await listScripts();
|
||||||
setScripts(data || []);
|
// map listScripts (basic) onto current scripts while preserving existing mappings where possible
|
||||||
|
const map = new Map(scripts.map((s) => [s.name, s]));
|
||||||
|
const merged: ScriptWithStatus[] = (data || []).map(
|
||||||
|
(si) => map.get(si.name) ?? { ...si, mappings: [], requirements_exists: false },
|
||||||
|
);
|
||||||
|
setScripts(merged);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
notification.error({ message: 'Failed to list scripts', description: err?.message || String(err) });
|
notification.error({ message: 'Failed to list scripts', description: err?.message || String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -104,9 +127,10 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
async function refreshStatuses() {
|
async function refreshStatuses() {
|
||||||
setFetchingStatuses(true);
|
setFetchingStatuses(true);
|
||||||
try {
|
try {
|
||||||
const all = await fetchScriptsStatusAll();
|
// fetchScriptsAll already returns mappings and requirements_exists; we reuse it
|
||||||
setStatuses(all);
|
const all = await fetchScriptsAll();
|
||||||
const count = all ? Object.keys(all).length : 0;
|
setScripts(all || []);
|
||||||
|
const count = all ? all.reduce((acc, s) => acc + (s.mappings?.length || 0), 0) : 0;
|
||||||
notification.info({ message: 'Statuses fetched', description: `Found ${count} service(s)` });
|
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) });
|
||||||
@@ -115,40 +139,22 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: collect UnitMapping[] for a given script name from statuses
|
// Helper to get mappings for a script (already available on the script object)
|
||||||
const getMappingsForScript = (scriptName: string): UnitMapping[] => {
|
const getMappingsForScript = (scriptName: string): UnitMapping[] => {
|
||||||
if (!statuses) return [];
|
const s = scripts.find((x) => x.name === scriptName);
|
||||||
const mappings: UnitMapping[] = [];
|
if (!s || !s.mappings) return [];
|
||||||
for (const svcKey of Object.keys(statuses)) {
|
return s.mappings.map((m) => ({
|
||||||
const us = statuses[svcKey];
|
service: m.service,
|
||||||
const parsed = us?.parsed as any;
|
parsed: {
|
||||||
// Prefer matching parsed.name if present, otherwise infer by service string
|
service: m.parsed?.service ?? m.service,
|
||||||
const parsedName = parsed?.name ?? null;
|
exec_start: m.parsed?.exec_start ?? null,
|
||||||
if (parsedName === scriptName || svcKey.includes(`-${scriptName}-q`)) {
|
name: m.parsed?.name ?? null,
|
||||||
mappings.push({
|
script_path: m.parsed?.script_path ?? null,
|
||||||
service: svcKey,
|
qnum: typeof m.parsed?.qnum === 'number' ? m.parsed.qnum : null,
|
||||||
parsed: {
|
extra: m.parsed?.extra ?? null,
|
||||||
service: parsed?.service ?? svcKey,
|
},
|
||||||
exec_start: parsed?.exec_start ?? null,
|
active: !!m.active,
|
||||||
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) {
|
||||||
@@ -168,6 +174,81 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requirements download
|
||||||
|
async function handleDownloadRequirements(name: string) {
|
||||||
|
try {
|
||||||
|
const blob = await downloadRequirements(name);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = 'requirements.txt';
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
notification.success({ message: 'requirements.txt download started' });
|
||||||
|
} catch (err: any) {
|
||||||
|
notification.error({ message: 'Download failed', description: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit requirements (download and open editor)
|
||||||
|
async function handleEditRequirements(name: string) {
|
||||||
|
try {
|
||||||
|
const blob = await downloadRequirements(name);
|
||||||
|
const text = await blob.text();
|
||||||
|
setReqEditorValue(text);
|
||||||
|
setReqEditingName(name);
|
||||||
|
setReqModalVisible(true);
|
||||||
|
} catch (err: any) {
|
||||||
|
notification.error({ message: 'Failed to open requirements', description: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save edited requirements (uploads and runs pip install)
|
||||||
|
async function handleSaveRequirements(name: string, content: string) {
|
||||||
|
try {
|
||||||
|
// create blob and upload via API -> returns pip output
|
||||||
|
const resp = await uploadRequirements(name, new Blob([content], { type: 'text/plain' }));
|
||||||
|
if (resp?.pip) {
|
||||||
|
setPipOutput(resp.pip);
|
||||||
|
setPipModalVisible(true);
|
||||||
|
}
|
||||||
|
setReqModalVisible(false);
|
||||||
|
setReqEditingName(null);
|
||||||
|
setReqEditorValue('');
|
||||||
|
// refresh
|
||||||
|
await refreshAll();
|
||||||
|
} catch (err: any) {
|
||||||
|
notification.error({ message: 'Failed to upload requirements', description: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteRequirements(name: string) {
|
||||||
|
Modal.confirm({
|
||||||
|
title: `Delete requirements for ${name}?`,
|
||||||
|
content: `This will remove requirements.txt and attempt to clean up the venv for ${name}.`,
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const res = await deleteRequirements(name);
|
||||||
|
notification.success({
|
||||||
|
message: 'requirements deleted',
|
||||||
|
description: `requirements removed: ${res.removed?.requirements_removed ? 'yes' : 'no'}`,
|
||||||
|
});
|
||||||
|
if (res.errors && res.errors.length) {
|
||||||
|
Modal.info({
|
||||||
|
title: 'Delete requirements returned errors',
|
||||||
|
content: <pre style={{ maxHeight: 240, overflow: 'auto' }}>{res.errors.join('\n')}</pre>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await refreshAll();
|
||||||
|
} catch (err: any) {
|
||||||
|
notification.error({ message: 'Failed to delete requirements', description: err?.message || String(err) });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(name: string) {
|
async function handleDelete(name: string) {
|
||||||
try {
|
try {
|
||||||
const res: DeleteResult = await deleteScript(name);
|
const res: DeleteResult = await deleteScript(name);
|
||||||
@@ -183,8 +264,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
refreshList();
|
await refreshAll();
|
||||||
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) });
|
||||||
}
|
}
|
||||||
@@ -203,9 +283,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
try {
|
try {
|
||||||
const res: OperationResult = await disableScript(scriptName, qnum);
|
const res: OperationResult = await disableScript(scriptName, qnum);
|
||||||
notification.success({ message: 'Disabled', description: `${mapping.service} disabled` });
|
notification.success({ message: 'Disabled', description: `${mapping.service} disabled` });
|
||||||
// refresh both lists and statuses
|
await refreshAll();
|
||||||
await refreshStatuses();
|
|
||||||
await refreshList();
|
|
||||||
} 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) });
|
||||||
}
|
}
|
||||||
@@ -248,8 +326,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
setUploadModalVisible(false);
|
setUploadModalVisible(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setCurrentEditingName(null);
|
setCurrentEditingName(null);
|
||||||
refreshList();
|
await refreshAll();
|
||||||
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) });
|
||||||
}
|
}
|
||||||
@@ -282,8 +359,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setEditorModalVisible(false);
|
setEditorModalVisible(false);
|
||||||
refreshList();
|
await refreshAll();
|
||||||
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) });
|
||||||
}
|
}
|
||||||
@@ -307,8 +383,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
setEnableModalVisible(false);
|
setEnableModalVisible(false);
|
||||||
setEnableTarget(null);
|
setEnableTarget(null);
|
||||||
enableForm.resetFields();
|
enableForm.resetFields();
|
||||||
refreshList();
|
await refreshAll();
|
||||||
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) });
|
||||||
}
|
}
|
||||||
@@ -316,12 +391,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
|
|
||||||
async function handleDisable(name: string) {
|
async function handleDisable(name: string) {
|
||||||
try {
|
try {
|
||||||
// if you prefer, open a modal to ask qnum — keeping default 0 for backward compatibility
|
// default qnum fallback
|
||||||
const qnum = 0;
|
const qnum = 0;
|
||||||
const res: OperationResult = await disableScript(name, qnum);
|
const res: OperationResult = await disableScript(name, qnum);
|
||||||
notification.success({ message: 'Disabled', description: `${name} disabled (service ${res.service})` });
|
notification.success({ message: 'Disabled', description: `${name} disabled (service ${res.service})` });
|
||||||
refreshList();
|
await refreshAll();
|
||||||
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) });
|
||||||
}
|
}
|
||||||
@@ -341,11 +415,31 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
key: 'path',
|
key: 'path',
|
||||||
render: (p) => p || '-',
|
render: (p) => p || '-',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Requirements',
|
||||||
|
key: 'requirements',
|
||||||
|
render: (_: any, record: ScriptWithStatus) =>
|
||||||
|
record.requirements_exists ? (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" onClick={() => handleDownloadRequirements(record.name)} icon={<DownloadOutlined />}>
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
<Button size="small" onClick={() => handleEditRequirements(record.name)} icon={<EditOutlined />}>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button size="small" danger onClick={() => handleDeleteRequirements(record.name)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: '#999' }}>—</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Actions',
|
title: 'Actions',
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
width: 320,
|
width: 320,
|
||||||
render: (_, record) => (
|
render: (_, record: ScriptWithStatus) => (
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title="Download">
|
<Tooltip title="Download">
|
||||||
<Button icon={<DownloadOutlined />} onClick={() => handleDownload(record.name)} />
|
<Button icon={<DownloadOutlined />} onClick={() => handleDownload(record.name)} />
|
||||||
@@ -410,7 +504,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
|
|
||||||
// Table expandable config
|
// Table expandable config
|
||||||
const expandable = {
|
const expandable = {
|
||||||
expandedRowRender: (record: ScriptInfo) => {
|
expandedRowRender: (record: ScriptWithStatus) => {
|
||||||
const mappings = getMappingsForScript(record.name);
|
const mappings = getMappingsForScript(record.name);
|
||||||
if (!mappings || mappings.length === 0) {
|
if (!mappings || mappings.length === 0) {
|
||||||
return <div style={{ padding: 12 }}>No running instances for this script.</div>;
|
return <div style={{ padding: 12 }}>No running instances for this script.</div>;
|
||||||
@@ -425,12 +519,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
rowExpandable: (record: ScriptInfo) => {
|
rowExpandable: (record: ScriptWithStatus) => {
|
||||||
const mappings = getMappingsForScript(record.name);
|
const mappings = getMappingsForScript(record.name);
|
||||||
return mappings.length > 0;
|
return mappings.length > 0;
|
||||||
},
|
},
|
||||||
defaultExpandAllRows: true,
|
defaultExpandAllRows: true, // everything expanded by default
|
||||||
// show expand icon in first column
|
|
||||||
expandRowByClick: false,
|
expandRowByClick: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -442,22 +535,8 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadModalVisible(true)}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadModalVisible(true)}>
|
||||||
Upload / New
|
Upload / New
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={refreshList}>Refresh</Button>
|
<Button onClick={refreshAll}>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>
|
||||||
@@ -482,11 +561,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
footer={null}
|
footer={null}
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={handleUpload}>
|
<Form form={form} layout="vertical" onFinish={handleUpload}>
|
||||||
<Form.Item name="name" label="Script name (include .py if you want)" rules={[{ required: true }]}>
|
<Form.Item name="name" label="Script name" rules={[{ required: true }]}>
|
||||||
<Input placeholder="my_script.py" />
|
<Input placeholder="my_script" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item label="Script file (optional if using editor below)">
|
<Form.Item label="Script file">
|
||||||
<Form.Item name="scriptFile" noStyle>
|
<Form.Item name="scriptFile" noStyle>
|
||||||
<Upload beforeUpload={() => false} maxCount={1}>
|
<Upload beforeUpload={() => false} maxCount={1}>
|
||||||
<Button icon={<UploadOutlined />}>Choose script file</Button>
|
<Button icon={<UploadOutlined />}>Choose script file</Button>
|
||||||
@@ -624,6 +703,45 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Requirements Editor Modal */}
|
||||||
|
<Modal
|
||||||
|
open={reqModalVisible}
|
||||||
|
title={reqEditingName ? `requirements.txt — ${reqEditingName}` : 'requirements.txt'}
|
||||||
|
onCancel={() => {
|
||||||
|
setReqModalVisible(false);
|
||||||
|
setReqEditingName(null);
|
||||||
|
setReqEditorValue('');
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setReqModalVisible(false);
|
||||||
|
setReqEditingName(null);
|
||||||
|
setReqEditorValue('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (!reqEditingName) {
|
||||||
|
notification.warning({ message: 'No target name' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleSaveRequirements(reqEditingName, reqEditorValue);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save & Install (runs pip)
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
width={800}
|
||||||
|
>
|
||||||
|
<PythonEditor value={reqEditorValue} onChange={setReqEditorValue} height={420} />
|
||||||
|
</Modal>
|
||||||
|
|
||||||
{/* Pip output modal */}
|
{/* Pip output modal */}
|
||||||
<Modal
|
<Modal
|
||||||
open={pipModalVisible}
|
open={pipModalVisible}
|
||||||
|
|||||||
@@ -3,13 +3,6 @@ export type ScriptInfo = {
|
|||||||
path: string;
|
path: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ScriptUploadResponse = ScriptInfo & {
|
|
||||||
pip?: {
|
|
||||||
stdout?: string;
|
|
||||||
stderr?: string;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UnitParsed = {
|
export type UnitParsed = {
|
||||||
service: string;
|
service: string;
|
||||||
exec_start?: string | null;
|
exec_start?: string | null;
|
||||||
@@ -35,6 +28,18 @@ export type StatusForNameResponse = {
|
|||||||
mappings: UnitMapping[];
|
mappings: UnitMapping[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ScriptWithStatus = ScriptInfo & {
|
||||||
|
mappings: UnitMapping[];
|
||||||
|
requirements_exists: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScriptUploadResponse = ScriptInfo & {
|
||||||
|
pip?: {
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type OperationResult = {
|
export type OperationResult = {
|
||||||
status: string;
|
status: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -58,3 +63,20 @@ export type EnableRequest = {
|
|||||||
extra_args?: string | null;
|
extra_args?: string | null;
|
||||||
enable_at_boot?: boolean | null;
|
enable_at_boot?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Response shape for requirements upload (pip output)
|
||||||
|
export type RequirementsUploadResult = {
|
||||||
|
pip: {
|
||||||
|
stdout?: string;
|
||||||
|
stderr?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Response shape for requirements delete
|
||||||
|
export type RequirementsDeleteResult = {
|
||||||
|
removed: {
|
||||||
|
requirements_removed: boolean;
|
||||||
|
venv_removed: boolean;
|
||||||
|
};
|
||||||
|
errors?: string[];
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user