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

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

View File

@@ -18,11 +18,11 @@ import shutil
import subprocess
import 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):
@@ -209,14 +218,7 @@ def parse_unit_execstart(service_name: str) -> Dict:
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:

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,55 @@ 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;