diff --git a/backend/src/api/packet_scripting_api.py b/backend/src/api/packet_scripting_api.py index 5a91da2..4c4b084 100644 --- a/backend/src/api/packet_scripting_api.py +++ b/backend/src/api/packet_scripting_api.py @@ -1,14 +1,15 @@ """ 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/{name} -> download script (binary blob) - - 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 + - POST /scripts -> upload script (multipart): script file, optional requirements file, required 'name' form field + - 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 """ 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] = [] - prefix = f"{UNIT_PREFIX}-{name}-q" - for svc in 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) + 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" + 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 + 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 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: diff --git a/frontend/src/api/apiClient.ts b/frontend/src/api/apiClient.ts index 9bd3518..398d1e0 100644 --- a/frontend/src/api/apiClient.ts +++ b/frontend/src/api/apiClient.ts @@ -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 => { Scripts ------------------------- */ -export const fetchScriptsStatusAll = async (): Promise> => { - const res = await api.get>('/scripts/scripts/status'); - return res.data; -}; - -export const fetchScriptStatusForName = async (name: string): Promise => { - const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/status`); +/** + * Fetch the combined scripts + status endpoint. + * Returns a list of ScriptWithStatus entries. + */ +export const fetchScriptsAll = async (): Promise => { + const res = await api.get('/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 => { - const res = await api.get('/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 => { + 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('/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 => { - // 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 => { + 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 => { + const fd = new FormData(); + fd.append('requirements', requirements); + const res = await api.put(`/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 => { + const res = await api.delete(`/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 => { const params: Record = {}; 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 => { const res = await api.post(`/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 => { - // backend expects qnum as query param (POST body null) const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, { params: { qnum }, }); diff --git a/frontend/src/components/ScriptManager.tsx b/frontend/src/components/ScriptManager.tsx index d431c77..7a084b7 100644 --- a/frontend/src/components/ScriptManager.tsx +++ b/frontend/src/components/ScriptManager.tsx @@ -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([]); + // now scripts holds ScriptWithStatus entries (name,path,mappings,requirements_exists) + const [scripts, setScripts] = useState([]); const [loading, setLoading] = useState(false); const [uploadModalVisible, setUploadModalVisible] = useState(false); const [editorModalVisible, setEditorModalVisible] = useState(false); const [editorValue, setEditorValue] = useState(''); const [currentEditingName, setCurrentEditingName] = useState(null); const [fetchingStatuses, setFetchingStatuses] = useState(false); - const [statuses, setStatuses] = useState | 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(''); + const [reqEditingName, setReqEditingName] = useState(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, - 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; + const s = scripts.find((x) => x.name === scriptName); + if (!s || !s.mappings) return []; + return s.mappings.map((m) => ({ + service: m.service, + parsed: { + 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: !!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:
{res.errors.join('\n')}
, + }); + } + 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 ? ( + + + + + + ) : ( + — + ), + }, { title: 'Actions', key: 'actions', width: 320, - render: (_, record) => ( + render: (_, record: ScriptWithStatus) => ( - + - {statuses && ( - - )} @@ -482,11 +561,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) { footer={null} >
- - + + - + false} maxCount={1}> @@ -624,6 +703,45 @@ export default function ScriptsManager({ onOpenInEditor }: Props) { + {/* Requirements Editor Modal */} + { + setReqModalVisible(false); + setReqEditingName(null); + setReqEditorValue(''); + }} + footer={ + + + + + } + width={800} + > + + + {/* Pip output modal */}