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:
@@ -2,13 +2,14 @@
|
||||
NFQUEUE Python-Scripting API Router
|
||||
Endpoints:
|
||||
- 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}/requirements -> download requirements file (binary blob) if present
|
||||
- PUT /scripts/{name}/requirements -> upload/replace requirements file (multipart). ALWAYS runs pip install and returns pip output.
|
||||
- DELETE /scripts/{name}/requirements -> delete only requirements file and remove venv (cleanup)
|
||||
- POST /scripts/{name}/enable -> enable systemd service for script on given qnum
|
||||
- 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
|
||||
- GET /scripts/status -> status of all fw-script units
|
||||
- GET /scripts/{name}/status -> status of units for that script
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -21,7 +22,7 @@ import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
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
|
||||
|
||||
# ---------- Configuration ----------
|
||||
@@ -364,6 +365,22 @@ class ScriptInfo(BaseModel):
|
||||
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):
|
||||
pip: Optional[Dict[str, str]] = None
|
||||
|
||||
@@ -375,12 +392,6 @@ class EnableRequest(BaseModel):
|
||||
enable_at_boot: Optional[bool] = False
|
||||
|
||||
|
||||
class UnitMapping(BaseModel):
|
||||
service: str
|
||||
parsed: UnitParsed
|
||||
active: bool = False
|
||||
|
||||
|
||||
class StatusForNameResponse(BaseModel):
|
||||
name: str
|
||||
mappings: List[UnitMapping]
|
||||
@@ -395,45 +406,47 @@ class OperationResult(BaseModel):
|
||||
|
||||
|
||||
# ---------- 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])
|
||||
def status_all() -> Dict[str, UnitStatus]:
|
||||
@router.get("", response_model=List[ScriptWithStatus])
|
||||
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()
|
||||
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
|
||||
out: List[ScriptWithStatus] = []
|
||||
|
||||
# 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)
|
||||
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: List[UnitMapping] = []
|
||||
for fn in os.listdir(SCRIPT_DIR):
|
||||
if not fn.endswith(".py"):
|
||||
continue
|
||||
name = fn.rsplit(".", 1)[0]
|
||||
spath = os.path.join(SCRIPT_DIR, fn)
|
||||
|
||||
# find units matching this script
|
||||
prefix = f"{UNIT_PREFIX}-{name}-q"
|
||||
for svc in units:
|
||||
mappings: List[UnitMapping] = []
|
||||
for svc in all_units:
|
||||
if svc.startswith(prefix):
|
||||
parsed = parse_unit_execstart(svc)
|
||||
try:
|
||||
active = is_unit_active(svc)
|
||||
except Exception:
|
||||
active = False
|
||||
matches.append(UnitMapping(service=svc, parsed=parsed, active=active))
|
||||
logger.debug("Status for %s -> %d matches", name, len(matches))
|
||||
return StatusForNameResponse(name=name, mappings=matches)
|
||||
mappings.append(UnitMapping(service=svc, parsed=parsed, active=active))
|
||||
|
||||
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)
|
||||
@@ -455,6 +468,10 @@ async def upload_script(
|
||||
logger.warning("Invalid name provided: %s", name)
|
||||
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)
|
||||
if os.path.exists(spath):
|
||||
logger.warning("Upload rejected: script with name already exists: %s", name)
|
||||
@@ -482,6 +499,7 @@ async def upload_script(
|
||||
fh.write(req_data)
|
||||
logger.info("Saved requirements for %s at %s", name, req_path)
|
||||
try:
|
||||
# create venv (if needed) and run pip install; pip output is returned in response
|
||||
create_venv(name)
|
||||
venv_created = True
|
||||
res = pip_install_requirements(name, req_path)
|
||||
@@ -500,6 +518,7 @@ async def upload_script(
|
||||
shutil.rmtree(venv_path_for(name), ignore_errors=True)
|
||||
except Exception:
|
||||
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}")
|
||||
except HTTPException:
|
||||
raise
|
||||
@@ -528,18 +547,6 @@ async def upload_script(
|
||||
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(
|
||||
"/{name}",
|
||||
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")
|
||||
|
||||
|
||||
@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)
|
||||
def enable_script(name: str, req: EnableRequest) -> OperationResult:
|
||||
try:
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
DeleteResult,
|
||||
EnableRequest,
|
||||
OperationResult,
|
||||
RequirementsDeleteResult,
|
||||
RequirementsUploadResult,
|
||||
ScriptInfo,
|
||||
ScriptUploadResponse,
|
||||
StatusForNameResponse,
|
||||
UnitStatus,
|
||||
ScriptWithStatus,
|
||||
StatusForNameResponse
|
||||
} from '../types/scripting';
|
||||
import { SnifferStatusResponse } from '../types/sniffer';
|
||||
|
||||
@@ -168,21 +170,42 @@ export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
|
||||
Scripts
|
||||
------------------------- */
|
||||
|
||||
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<StatusForNameResponse> => {
|
||||
const res = await api.get<StatusForNameResponse>(`/scripts/scripts/${encodeURIComponent(name)}/status`);
|
||||
/**
|
||||
* Fetch the combined scripts + status endpoint.
|
||||
* Returns a list of ScriptWithStatus entries.
|
||||
*/
|
||||
export const fetchScriptsAll = async (): Promise<ScriptWithStatus[]> => {
|
||||
const res = await api.get<ScriptWithStatus[]>('/scripts/scripts');
|
||||
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[]> => {
|
||||
const res = await api.get<ScriptInfo[]>('/scripts/scripts');
|
||||
return res.data;
|
||||
const all = await fetchScriptsAll();
|
||||
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: {
|
||||
name: string;
|
||||
script: File | Blob;
|
||||
@@ -193,19 +216,59 @@ export const uploadScript = async (opts: {
|
||||
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<ScriptUploadResponse>('/scripts/scripts', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-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> => {
|
||||
// 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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> => {
|
||||
const params: Record<string, any> = {};
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable a script (creates + starts a systemd unit). Returns OperationResult including service name.
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Disable a script unit (POST with qnum as query param). Returns 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, {
|
||||
params: { qnum },
|
||||
});
|
||||
|
||||
@@ -27,12 +27,15 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
deleteRequirements,
|
||||
deleteScript,
|
||||
disableScript,
|
||||
downloadRequirements,
|
||||
downloadScript,
|
||||
enableScript,
|
||||
fetchScriptsStatusAll,
|
||||
fetchScriptsAll,
|
||||
listScripts,
|
||||
uploadRequirements,
|
||||
uploadScript,
|
||||
} from '../api/apiClient';
|
||||
import PythonEditor from '../components/PythonEditor';
|
||||
@@ -42,7 +45,7 @@ import {
|
||||
OperationResult,
|
||||
ScriptInfo,
|
||||
ScriptUploadResponse,
|
||||
UnitStatus,
|
||||
ScriptWithStatus,
|
||||
} from '../types/scripting';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
@@ -67,33 +70,53 @@ type UnitMapping = {
|
||||
};
|
||||
|
||||
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 [uploadModalVisible, setUploadModalVisible] = useState(false);
|
||||
const [editorModalVisible, setEditorModalVisible] = useState(false);
|
||||
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);
|
||||
|
||||
// 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 [enableForm] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
refreshList();
|
||||
// optional: fetch statuses immediately as well
|
||||
refreshStatuses();
|
||||
refreshAll();
|
||||
}, []);
|
||||
|
||||
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() {
|
||||
setLoading(true);
|
||||
try {
|
||||
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) {
|
||||
notification.error({ message: 'Failed to list scripts', description: err?.message || String(err) });
|
||||
} finally {
|
||||
@@ -104,9 +127,10 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
async function refreshStatuses() {
|
||||
setFetchingStatuses(true);
|
||||
try {
|
||||
const all = await fetchScriptsStatusAll();
|
||||
setStatuses(all);
|
||||
const count = all ? Object.keys(all).length : 0;
|
||||
// fetchScriptsAll already returns mappings and requirements_exists; we reuse it
|
||||
const all = await fetchScriptsAll();
|
||||
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)` });
|
||||
} catch (err: any) {
|
||||
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[] => {
|
||||
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,
|
||||
const s = scripts.find((x) => x.name === scriptName);
|
||||
if (!s || !s.mappings) return [];
|
||||
return s.mappings.map((m) => ({
|
||||
service: m.service,
|
||||
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,
|
||||
service: m.parsed?.service ?? m.service,
|
||||
exec_start: m.parsed?.exec_start ?? null,
|
||||
name: m.parsed?.name ?? null,
|
||||
script_path: m.parsed?.script_path ?? null,
|
||||
qnum: typeof m.parsed?.qnum === 'number' ? m.parsed.qnum : null,
|
||||
extra: m.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;
|
||||
active: !!m.active,
|
||||
}));
|
||||
};
|
||||
|
||||
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) {
|
||||
try {
|
||||
const res: DeleteResult = await deleteScript(name);
|
||||
@@ -183,8 +264,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
),
|
||||
});
|
||||
}
|
||||
refreshList();
|
||||
refreshStatuses();
|
||||
await refreshAll();
|
||||
} catch (err: any) {
|
||||
notification.error({ message: 'Delete failed', description: err?.message || String(err) });
|
||||
}
|
||||
@@ -203,9 +283,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
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();
|
||||
await refreshAll();
|
||||
} catch (err: any) {
|
||||
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
|
||||
}
|
||||
@@ -248,8 +326,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
setUploadModalVisible(false);
|
||||
form.resetFields();
|
||||
setCurrentEditingName(null);
|
||||
refreshList();
|
||||
refreshStatuses();
|
||||
await refreshAll();
|
||||
} catch (err: any) {
|
||||
notification.error({ message: 'Upload failed', description: err?.message || String(err) });
|
||||
}
|
||||
@@ -282,8 +359,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
}
|
||||
|
||||
setEditorModalVisible(false);
|
||||
refreshList();
|
||||
refreshStatuses();
|
||||
await refreshAll();
|
||||
} catch (err: any) {
|
||||
notification.error({ message: 'Save failed', description: err?.message || String(err) });
|
||||
}
|
||||
@@ -307,8 +383,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
setEnableModalVisible(false);
|
||||
setEnableTarget(null);
|
||||
enableForm.resetFields();
|
||||
refreshList();
|
||||
refreshStatuses();
|
||||
await refreshAll();
|
||||
} catch (err: any) {
|
||||
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) {
|
||||
try {
|
||||
// if you prefer, open a modal to ask qnum — keeping default 0 for backward compatibility
|
||||
// default qnum fallback
|
||||
const qnum = 0;
|
||||
const res: OperationResult = await disableScript(name, qnum);
|
||||
notification.success({ message: 'Disabled', description: `${name} disabled (service ${res.service})` });
|
||||
refreshList();
|
||||
refreshStatuses();
|
||||
await refreshAll();
|
||||
} catch (err: any) {
|
||||
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
|
||||
}
|
||||
@@ -341,11 +415,31 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
key: 'path',
|
||||
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',
|
||||
key: 'actions',
|
||||
width: 320,
|
||||
render: (_, record) => (
|
||||
render: (_, record: ScriptWithStatus) => (
|
||||
<Space>
|
||||
<Tooltip title="Download">
|
||||
<Button icon={<DownloadOutlined />} onClick={() => handleDownload(record.name)} />
|
||||
@@ -410,7 +504,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
|
||||
// Table expandable config
|
||||
const expandable = {
|
||||
expandedRowRender: (record: ScriptInfo) => {
|
||||
expandedRowRender: (record: ScriptWithStatus) => {
|
||||
const mappings = getMappingsForScript(record.name);
|
||||
if (!mappings || mappings.length === 0) {
|
||||
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);
|
||||
return mappings.length > 0;
|
||||
},
|
||||
defaultExpandAllRows: true,
|
||||
// show expand icon in first column
|
||||
defaultExpandAllRows: true, // everything expanded by default
|
||||
expandRowByClick: false,
|
||||
};
|
||||
|
||||
@@ -442,22 +535,8 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadModalVisible(true)}>
|
||||
Upload / New
|
||||
</Button>
|
||||
<Button onClick={refreshList}>Refresh</Button>
|
||||
<Button onClick={refreshAll}>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>
|
||||
@@ -482,11 +561,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
footer={null}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleUpload}>
|
||||
<Form.Item name="name" label="Script name (include .py if you want)" rules={[{ required: true }]}>
|
||||
<Input placeholder="my_script.py" />
|
||||
<Form.Item name="name" label="Script name" rules={[{ required: true }]}>
|
||||
<Input placeholder="my_script" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Script file (optional if using editor below)">
|
||||
<Form.Item label="Script file">
|
||||
<Form.Item name="scriptFile" noStyle>
|
||||
<Upload beforeUpload={() => false} maxCount={1}>
|
||||
<Button icon={<UploadOutlined />}>Choose script file</Button>
|
||||
@@ -624,6 +703,45 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
</Form>
|
||||
</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 */}
|
||||
<Modal
|
||||
open={pipModalVisible}
|
||||
|
||||
@@ -3,13 +3,6 @@ export type ScriptInfo = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ScriptUploadResponse = ScriptInfo & {
|
||||
pip?: {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type UnitParsed = {
|
||||
service: string;
|
||||
exec_start?: string | null;
|
||||
@@ -35,6 +28,18 @@ export type StatusForNameResponse = {
|
||||
mappings: UnitMapping[];
|
||||
};
|
||||
|
||||
export type ScriptWithStatus = ScriptInfo & {
|
||||
mappings: UnitMapping[];
|
||||
requirements_exists: boolean;
|
||||
};
|
||||
|
||||
export type ScriptUploadResponse = ScriptInfo & {
|
||||
pip?: {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type OperationResult = {
|
||||
status: string;
|
||||
name: string;
|
||||
@@ -58,3 +63,20 @@ export type EnableRequest = {
|
||||
extra_args?: string | 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