script improvements
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user