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

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