script improvements
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s

This commit is contained in:
2026-03-01 10:44:58 +01:00
parent 0dff3ade09
commit ed54050d6b
4 changed files with 512 additions and 163 deletions

View File

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

View File

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

View File

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

View File

@@ -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[];
};