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

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

View File

@@ -9,7 +9,15 @@ import {
InterfaceInfo,
RouteInfo,
} from '../types/network';
import { EnableRequest, ScriptInfo } from '../types/scripting';
import {
DeleteResult,
EnableRequest,
OperationResult,
ScriptInfo,
ScriptUploadResponse,
StatusForNameResponse,
UnitStatus,
} from '../types/scripting';
import { SnifferStatusResponse } from '../types/sniffer';
const BASE = 'http://mitm.lan/api';
@@ -17,7 +25,7 @@ const BASE = 'http://mitm.lan/api';
export const api = axios.create({
baseURL: BASE,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
timeout: 20000,
});
// Normalize FastAPI errors here
@@ -160,13 +168,13 @@ export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
Scripts
------------------------- */
export const fetchScriptsStatusAll = async (): Promise<any> => {
const res = await api.get('/scripts/scripts/status');
export const fetchScriptsStatusAll = async (): Promise<Record<string, UnitStatus>> => {
const res = await api.get<Record<string, UnitStatus>>('/scripts/scripts/status');
return res.data;
};
export const fetchScriptStatusForName = async (name: string): Promise<any> => {
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/status`);
export const fetchScriptStatusForName = async (name: string): Promise<StatusForNameResponse> => {
const res = await api.get<StatusForNameResponse>(`/scripts/scripts/${encodeURIComponent(name)}/status`);
return res.data;
};
@@ -179,38 +187,40 @@ export const uploadScript = async (opts: {
name: string;
script: File | Blob;
requirements?: File | Blob | null;
}): Promise<ScriptInfo> => {
}): Promise<ScriptUploadResponse> => {
const fd = new FormData();
fd.append('name', opts.name);
fd.append('script', opts.script);
if (opts.requirements) fd.append('requirements', opts.requirements as Blob);
// axios will set multipart/form-data boundary automatically when FormData passed
const res = await api.post<ScriptInfo>('/scripts/scripts', fd, {
const res = await api.post<ScriptUploadResponse>('/scripts/scripts', fd, {
headers: { 'Content-Type': 'multipart/form-data' },
});
return res.data;
};
export const downloadScript = async (name: string): Promise<any> => {
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`);
return res.data;
export const downloadScript = async (name: string): Promise<Blob> => {
// expect the backend to return a FileResponse. request as blob to trigger browser download handling.
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`, { responseType: 'blob' });
return res.data as Blob;
};
export const deleteScript = async (name: string, qnum?: number | null): Promise<any> => {
export const deleteScript = async (name: string, qnum?: number | null): Promise<DeleteResult> => {
const params: Record<string, any> = {};
if (typeof qnum !== 'undefined') params.qnum = qnum;
const res = await api.delete(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
if (typeof qnum !== 'undefined' && qnum !== null) params.qnum = qnum;
const res = await api.delete<DeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
return res.data;
};
export const enableScript = async (name: string, req: EnableRequest): Promise<any> => {
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
export const enableScript = async (name: string, req: EnableRequest): Promise<OperationResult> => {
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
return res.data;
};
export const disableScript = async (name: string, qnum: number): Promise<any> => {
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
export const disableScript = async (name: string, qnum: number): Promise<OperationResult> => {
// backend expects qnum as query param (POST body null)
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
params: { qnum },
});
return res.data;

View File

@@ -11,6 +11,7 @@ export type PythonEditorProps = {
readOnly?: boolean;
className?: string;
height?: string | number; // e.g. '200px' or 200
wrap?: boolean; // if true, long lines will wrap; default false (no wrap)
};
export default function PythonEditor({
@@ -19,6 +20,7 @@ export default function PythonEditor({
readOnly = false,
height = 480,
className = '',
wrap = false,
}: PythonEditorProps) {
const highlight = useCallback((code: string) => {
try {
@@ -28,22 +30,43 @@ export default function PythonEditor({
}
}, []);
// container style: fixed height + scrolling
const containerStyle = useMemo(() => {
const h = typeof height === 'number' ? `${height}px` : height;
return {
height: h,
maxHeight: h,
overflow: 'auto' as const, // enable scrolling
position: 'relative' as const,
borderRadius: 6,
border: '1px solid rgba(0,0,0,0.06)',
background: 'var(--code-bg, white)',
};
}, [height]);
// editor style: fill container and use monospace font
const editorStyle = useMemo(
() => ({
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
fontSize: 14,
height: typeof height === 'number' ? `${height}px` : height,
// make editor fill the container (pre/textarea inside will take up height)
minHeight: '100%',
boxSizing: 'border-box' as const,
padding: 12,
outline: 'none',
whiteSpace: wrap ? 'pre-wrap' : 'pre', // pre-wrap if wrap requested
wordBreak: 'break-word' as const,
}),
[height],
[wrap],
);
return (
<div className={`python-editor ${className}`}>
<div className={`python-editor ${className}`} style={containerStyle}>
<Editor
value={value}
onValueChange={(code) => onChange(code)}
highlight={highlight}
padding={12}
padding={0} // padding handled by editorStyle
preClassName="language-python"
textareaId="python-editor"
style={editorStyle}

View File

@@ -20,6 +20,7 @@ import {
Spin,
Table,
Tooltip,
Typography,
Upload,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
@@ -35,12 +36,36 @@ import {
uploadScript,
} from '../api/apiClient';
import PythonEditor from '../components/PythonEditor';
import { EnableRequest, ScriptInfo } from '../types/scripting';
import {
DeleteResult,
EnableRequest,
OperationResult,
ScriptInfo,
ScriptUploadResponse,
UnitStatus,
} from '../types/scripting';
const { Paragraph } = Typography;
type Props = {
onOpenInEditor?: (code: string) => void;
};
type UnitParsedLocal = {
service: string;
exec_start?: string | null;
name?: string | null;
script_path?: string | null;
qnum?: number | null;
extra?: string | null;
};
type UnitMapping = {
service: string;
parsed: UnitParsedLocal;
active: boolean;
};
export default function ScriptsManager({ onOpenInEditor }: Props) {
const [scripts, setScripts] = useState<ScriptInfo[]>([]);
const [loading, setLoading] = useState(false);
@@ -49,11 +74,19 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
const [editorValue, setEditorValue] = useState<string>('');
const [currentEditingName, setCurrentEditingName] = useState<string | null>(null);
const [fetchingStatuses, setFetchingStatuses] = useState(false);
const [statuses, setStatuses] = useState<Record<string, UnitStatus> | null>(null);
// pip output modal
const [pipModalVisible, setPipModalVisible] = useState(false);
const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null);
const [form] = Form.useForm();
const [enableForm] = Form.useForm();
useEffect(() => {
refreshList();
// optional: fetch statuses immediately as well
refreshStatuses();
}, []);
async function refreshList() {
@@ -72,9 +105,9 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
setFetchingStatuses(true);
try {
const all = await fetchScriptsStatusAll();
// assume the API returns keyed info matching script names. adapt as needed
// For simplicity, re-fetch list (which may include status), but if you have separate structure, merge.
await refreshList();
setStatuses(all);
const count = all ? Object.keys(all).length : 0;
notification.info({ message: 'Statuses fetched', description: `Found ${count} service(s)` });
} catch (err: any) {
notification.error({ message: 'Failed to fetch statuses', description: err?.message || String(err) });
} finally {
@@ -82,29 +115,45 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
}
}
// Helper: collect UnitMapping[] for a given script name from statuses
const getMappingsForScript = (scriptName: string): UnitMapping[] => {
if (!statuses) return [];
const mappings: UnitMapping[] = [];
for (const svcKey of Object.keys(statuses)) {
const us = statuses[svcKey];
const parsed = us?.parsed as any;
// Prefer matching parsed.name if present, otherwise infer by service string
const parsedName = parsed?.name ?? null;
if (parsedName === scriptName || svcKey.includes(`-${scriptName}-q`)) {
mappings.push({
service: svcKey,
parsed: {
service: parsed?.service ?? svcKey,
exec_start: parsed?.exec_start ?? null,
name: parsed?.name ?? null,
script_path: parsed?.script_path ?? null,
qnum: typeof parsed?.qnum === 'number' ? parsed.qnum : null,
extra: parsed?.extra ?? null,
},
active: !!us?.active,
});
}
}
// sort by qnum (nulls last)
mappings.sort((a, b) => {
const aq = a.parsed.qnum;
const bq = b.parsed.qnum;
if (aq == null && bq == null) return 0;
if (aq == null) return 1;
if (bq == null) return -1;
return aq - bq;
});
return mappings;
};
async function handleDownload(name: string) {
try {
const res = await downloadScript(name);
// Expecting server returns { filename?: string, content?: string/base64 } or actual text
// If server returns raw script text, just download it as blob
let blob: Blob;
if (typeof res === 'string') {
blob = new Blob([res], { type: 'text/x-python' });
} else if (res && res.content && res.filename) {
// maybe base64
try {
const b = atob(res.content);
const arr = new Uint8Array(b.length);
for (let i = 0; i < b.length; i++) arr[i] = b.charCodeAt(i);
blob = new Blob([arr], { type: 'text/x-python' });
} catch {
blob = new Blob([res.content], { type: 'text/x-python' });
}
} else {
// fallback: JSON
blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' });
}
const blob = await downloadScript(name); // Blob
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -121,14 +170,49 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
async function handleDelete(name: string) {
try {
await deleteScript(name);
const res: DeleteResult = await deleteScript(name);
notification.success({ message: 'Deleted', description: `${name} removed` });
if (res.errors && res.errors.length > 0) {
Modal.info({
title: 'Delete completed with errors',
content: (
<div>
<Paragraph>Errors occurred while deleting resources:</Paragraph>
<pre style={{ maxHeight: 240, overflow: 'auto' }}>{res.errors.join('\n')}</pre>
</div>
),
});
}
refreshList();
refreshStatuses();
} catch (err: any) {
notification.error({ message: 'Delete failed', description: err?.message || String(err) });
}
}
async function handleDisableInstance(scriptName: string, mapping: UnitMapping) {
const qnum = mapping.parsed.qnum;
if (typeof qnum !== 'number') {
notification.warning({ message: 'Cannot disable instance', description: 'qnum not available for this unit' });
return;
}
Modal.confirm({
title: `Disable ${mapping.service}?`,
content: `This will attempt to stop and remove unit ${mapping.service} (qnum ${qnum}).`,
onOk: async () => {
try {
const res: OperationResult = await disableScript(scriptName, qnum);
notification.success({ message: 'Disabled', description: `${mapping.service} disabled` });
// refresh both lists and statuses
await refreshStatuses();
await refreshList();
} catch (err: any) {
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
}
},
});
}
function fileToBlob(file: File | Blob) {
return file;
}
@@ -148,16 +232,24 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
const req = requirementsFile?.file || null;
try {
await uploadScript({
const resp: ScriptUploadResponse = await uploadScript({
name,
script,
requirements: req,
});
notification.success({ message: 'Uploaded', description: `${name} uploaded` });
if (resp.pip && (resp.pip.stdout || resp.pip.stderr)) {
setPipOutput(resp.pip);
setPipModalVisible(true);
}
setUploadModalVisible(false);
form.resetFields();
setCurrentEditingName(null);
refreshList();
refreshStatuses();
} catch (err: any) {
notification.error({ message: 'Upload failed', description: err?.message || String(err) });
}
@@ -165,19 +257,8 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
async function openInEditorFromServer(name: string) {
try {
const res = await downloadScript(name);
let text = '';
if (typeof res === 'string') text = res;
else if (res && res.content) {
// maybe base64
try {
text = atob(res.content);
} catch {
text = typeof res.content === 'string' ? res.content : JSON.stringify(res.content);
}
} else {
text = JSON.stringify(res);
}
const blob = await downloadScript(name); // Blob
const text = await blob.text();
setEditorValue(text);
setEditorModalVisible(true);
setCurrentEditingName(name);
@@ -189,14 +270,20 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
async function handleSaveFromEditorAs(name: string) {
try {
// create blob from editor and upload
await uploadScript({
const resp: ScriptUploadResponse = await uploadScript({
name,
script: new Blob([editorValue], { type: 'text/x-python' }),
});
notification.success({ message: 'Saved', description: `${name} uploaded` });
if (resp.pip && (resp.pip.stdout || resp.pip.stderr)) {
setPipOutput(resp.pip);
setPipModalVisible(true);
}
setEditorModalVisible(false);
refreshList();
refreshStatuses();
} catch (err: any) {
notification.error({ message: 'Save failed', description: err?.message || String(err) });
}
@@ -205,7 +292,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
// enable modal/form
const [enableModalVisible, setEnableModalVisible] = useState(false);
const [enableTarget, setEnableTarget] = useState<string | null>(null);
const [enableForm] = Form.useForm();
async function handleEnableSubmit(values: any) {
if (!enableTarget) return;
@@ -216,12 +302,13 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
enable_at_boot: !!values.enable_at_boot,
};
try {
await enableScript(enableTarget, req);
notification.success({ message: 'Enabled', description: `${enableTarget} enabled` });
const res: OperationResult = await enableScript(enableTarget, req);
notification.success({ message: 'Enabled', description: `${enableTarget} enabled as ${res.service}` });
setEnableModalVisible(false);
setEnableTarget(null);
enableForm.resetFields();
refreshList();
refreshStatuses();
} catch (err: any) {
notification.error({ message: 'Enable failed', description: err?.message || String(err) });
}
@@ -229,16 +316,18 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
async function handleDisable(name: string) {
try {
// ask for qnum? assume qnum required; if you know qnum mapping show UI to input; here we default to 0 if not known
// if you prefer, open a modal to ask qnum — keeping default 0 for backward compatibility
const qnum = 0;
await disableScript(name, qnum);
notification.success({ message: 'Disabled', description: `${name} disabled` });
const res: OperationResult = await disableScript(name, qnum);
notification.success({ message: 'Disabled', description: `${name} disabled (service ${res.service})` });
refreshList();
refreshStatuses();
} catch (err: any) {
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
}
}
// Table columns for main script list
const columns: ColumnsType<ScriptInfo> = [
{
title: 'Name',
@@ -255,6 +344,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
{
title: 'Actions',
key: 'actions',
width: 320,
render: (_, record) => (
<Space>
<Tooltip title="Download">
@@ -272,7 +362,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
}}
/>
</Tooltip>
<Tooltip title="Disable">
<Tooltip title="Disable (defaults to qnum=0)">
<Button icon={<PauseCircleOutlined />} onClick={() => handleDisable(record.name)} />
</Tooltip>
<Popconfirm title={`Delete ${record.name}?`} onConfirm={() => handleDelete(record.name)}>
@@ -283,6 +373,67 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
},
];
// nested columns for unit mappings when a script row is expanded
const nestedColumns = [
{
title: 'Service',
dataIndex: ['parsed', 'service'],
key: 'service',
render: (_: any, r: UnitMapping) => <code>{r.service}</code>,
},
{ title: 'qnum', dataIndex: ['parsed', 'qnum'], key: 'qnum', render: (q: number | null) => (q == null ? '-' : q) },
{ title: 'Active', dataIndex: 'active', key: 'active', render: (a: boolean) => (a ? 'yes' : 'no') },
{ title: 'Extra', dataIndex: ['parsed', 'extra'], key: 'extra', render: (e: string | null) => e || '-' },
{
title: 'ExecStart',
dataIndex: ['parsed', 'exec_start'],
key: 'exec_start',
render: (s: string | null) => s || '-',
},
{
title: 'Actions',
key: 'unit_actions',
render: (_: any, r: UnitMapping) => (
<Space>
<Button
onClick={() => handleDisableInstance(r.parsed.name ?? r.parsed.service ?? '', r)}
danger
icon={<PauseCircleOutlined />}
disabled={typeof r.parsed.qnum !== 'number'}
>
Disable instance
</Button>
</Space>
),
},
];
// Table expandable config
const expandable = {
expandedRowRender: (record: ScriptInfo) => {
const mappings = getMappingsForScript(record.name);
if (!mappings || mappings.length === 0) {
return <div style={{ padding: 12 }}>No running instances for this script.</div>;
}
return (
<Table
columns={nestedColumns}
dataSource={mappings}
pagination={false}
rowKey={(r: UnitMapping) => r.service}
size="small"
/>
);
},
rowExpandable: (record: ScriptInfo) => {
const mappings = getMappingsForScript(record.name);
return mappings.length > 0;
},
defaultExpandAllRows: true,
// show expand icon in first column
expandRowByClick: false,
};
return (
<div>
<Row justify="space-between" style={{ marginBottom: 12 }}>
@@ -293,11 +444,32 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
</Button>
<Button onClick={refreshList}>Refresh</Button>
<Button onClick={refreshStatuses}>{fetchingStatuses ? <Spin size="small" /> : 'Statuses'}</Button>
{statuses && (
<Button
onClick={() =>
Modal.info({
title: 'Status summary',
content: (
<pre style={{ maxHeight: 300, overflow: 'auto' }}>{JSON.stringify(statuses, null, 2)}</pre>
),
})
}
>
View Raw Status
</Button>
)}
</Space>
</Col>
</Row>
<Table rowKey="name" columns={columns} dataSource={scripts} loading={loading} pagination={{ pageSize: 10 }} />
<Table
rowKey="name"
columns={columns}
dataSource={scripts}
loading={loading}
pagination={{ pageSize: 10 }}
expandable={expandable}
/>
{/* Upload Modal */}
<Modal
@@ -364,7 +536,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
<Button
type="default"
onClick={() => {
// Save as new name
Modal.confirm({
title: 'Save as',
content: (
@@ -396,7 +567,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
<Button
type="primary"
onClick={() => {
// overwrite current
if (!currentEditingName) {
notification.warning({ message: 'No filename to overwrite' });
return;
@@ -453,6 +623,25 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
</Form.Item>
</Form>
</Modal>
{/* Pip output modal */}
<Modal
open={pipModalVisible}
title="pip install output"
onCancel={() => {
setPipModalVisible(false);
setPipOutput(null);
}}
footer={<Button onClick={() => setPipModalVisible(false)}>Close</Button>}
width={800}
>
<div style={{ maxHeight: 420, overflow: 'auto' }}>
<h4>STDOUT</h4>
<pre style={{ whiteSpace: 'pre-wrap' }}>{pipOutput?.stdout ?? '(none)'}</pre>
<h4>STDERR</h4>
<pre style={{ whiteSpace: 'pre-wrap', color: 'red' }}>{pipOutput?.stderr ?? '(none)'}</pre>
</div>
</Modal>
</div>
);
}

View File

@@ -3,9 +3,58 @@ export type ScriptInfo = {
path: string;
};
export type ScriptUploadResponse = ScriptInfo & {
pip?: {
stdout?: string;
stderr?: string;
} | null;
};
export type UnitParsed = {
service: string;
exec_start?: string | null;
name?: string | null;
script_path?: string | null;
qnum?: number | null;
extra?: string | null;
};
export type UnitStatus = {
parsed: UnitParsed;
active: boolean;
};
export type UnitMapping = {
service: string;
parsed: UnitParsed;
active: boolean;
};
export type StatusForNameResponse = {
name: string;
mappings: UnitMapping[];
};
export type OperationResult = {
status: string;
name: string;
qnum?: number | null;
service?: string | null;
python?: string | null;
};
export type DeleteResult = {
name: string;
units_removed: string[];
units_failed: string[];
files_removed: string[];
files_failed: string[];
errors: string[];
};
export type EnableRequest = {
qnum: number;
service_name?: string | null;
extra_args?: string | null;
enable_at_boot?: boolean | null;
};
};