All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 8s
459 lines
15 KiB
TypeScript
459 lines
15 KiB
TypeScript
import {
|
|
DeleteOutlined,
|
|
DownloadOutlined,
|
|
EditOutlined,
|
|
PauseCircleOutlined,
|
|
PlayCircleOutlined,
|
|
PlusOutlined,
|
|
UploadOutlined,
|
|
} from '@ant-design/icons';
|
|
import {
|
|
Button,
|
|
Col,
|
|
Form,
|
|
Input,
|
|
Modal,
|
|
notification,
|
|
Popconfirm,
|
|
Row,
|
|
Space,
|
|
Spin,
|
|
Table,
|
|
Tooltip,
|
|
Upload,
|
|
} from 'antd';
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
import { useEffect, useState } from 'react';
|
|
|
|
import {
|
|
deleteScript,
|
|
disableScript,
|
|
downloadScript,
|
|
enableScript,
|
|
fetchScriptsStatusAll,
|
|
listScripts,
|
|
uploadScript,
|
|
} from '../api/apiClient';
|
|
import PythonEditor from '../components/PythonEditor';
|
|
import { EnableRequest, ScriptInfo } from '../types/scripting';
|
|
|
|
type Props = {
|
|
onOpenInEditor?: (code: string) => void;
|
|
};
|
|
|
|
export default function ScriptsManager({ onOpenInEditor }: Props) {
|
|
const [scripts, setScripts] = useState<ScriptInfo[]>([]);
|
|
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 [form] = Form.useForm();
|
|
|
|
useEffect(() => {
|
|
refreshList();
|
|
}, []);
|
|
|
|
async function refreshList() {
|
|
setLoading(true);
|
|
try {
|
|
const data = await listScripts();
|
|
setScripts(data || []);
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Failed to list scripts', description: err?.message || String(err) });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function refreshStatuses() {
|
|
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();
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Failed to fetch statuses', description: err?.message || String(err) });
|
|
} finally {
|
|
setFetchingStatuses(false);
|
|
}
|
|
}
|
|
|
|
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 url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = name.endsWith('.py') ? name : `${name}.py`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
notification.success({ message: 'Download started' });
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Download failed', description: err?.message || String(err) });
|
|
}
|
|
}
|
|
|
|
async function handleDelete(name: string) {
|
|
try {
|
|
await deleteScript(name);
|
|
notification.success({ message: 'Deleted', description: `${name} removed` });
|
|
refreshList();
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Delete failed', description: err?.message || String(err) });
|
|
}
|
|
}
|
|
|
|
function fileToBlob(file: File | Blob) {
|
|
return file;
|
|
}
|
|
|
|
async function handleUpload(formValues: any) {
|
|
const { name, scriptFile, requirementsFile } = formValues;
|
|
if (!name || (!scriptFile && !editorValue && !currentEditingName)) {
|
|
notification.warning({ message: 'Please provide a name and script' });
|
|
return;
|
|
}
|
|
|
|
const script =
|
|
scriptFile?.file ||
|
|
(currentEditingName
|
|
? new Blob([editorValue], { type: 'text/x-python' })
|
|
: new Blob([editorValue], { type: 'text/x-python' }));
|
|
const req = requirementsFile?.file || null;
|
|
|
|
try {
|
|
await uploadScript({
|
|
name,
|
|
script,
|
|
requirements: req,
|
|
});
|
|
notification.success({ message: 'Uploaded', description: `${name} uploaded` });
|
|
setUploadModalVisible(false);
|
|
form.resetFields();
|
|
setCurrentEditingName(null);
|
|
refreshList();
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Upload failed', description: err?.message || String(err) });
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
setEditorValue(text);
|
|
setEditorModalVisible(true);
|
|
setCurrentEditingName(name);
|
|
if (onOpenInEditor) onOpenInEditor(text);
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Failed to open', description: err?.message || String(err) });
|
|
}
|
|
}
|
|
|
|
async function handleSaveFromEditorAs(name: string) {
|
|
try {
|
|
// create blob from editor and upload
|
|
await uploadScript({
|
|
name,
|
|
script: new Blob([editorValue], { type: 'text/x-python' }),
|
|
});
|
|
notification.success({ message: 'Saved', description: `${name} uploaded` });
|
|
setEditorModalVisible(false);
|
|
refreshList();
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Save failed', description: err?.message || String(err) });
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
const req: EnableRequest = {
|
|
qnum: Number(values.qnum),
|
|
service_name: values.service_name || null,
|
|
extra_args: values.extra_args || null,
|
|
enable_at_boot: !!values.enable_at_boot,
|
|
};
|
|
try {
|
|
await enableScript(enableTarget, req);
|
|
notification.success({ message: 'Enabled', description: `${enableTarget} enabled` });
|
|
setEnableModalVisible(false);
|
|
setEnableTarget(null);
|
|
enableForm.resetFields();
|
|
refreshList();
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Enable failed', description: err?.message || String(err) });
|
|
}
|
|
}
|
|
|
|
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
|
|
const qnum = 0;
|
|
await disableScript(name, qnum);
|
|
notification.success({ message: 'Disabled', description: `${name} disabled` });
|
|
refreshList();
|
|
} catch (err: any) {
|
|
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
|
|
}
|
|
}
|
|
|
|
const columns: ColumnsType<ScriptInfo> = [
|
|
{
|
|
title: 'Name',
|
|
dataIndex: 'name',
|
|
key: 'name',
|
|
render: (text) => <code>{text}</code>,
|
|
},
|
|
{
|
|
title: 'Path',
|
|
dataIndex: 'path',
|
|
key: 'path',
|
|
render: (p) => p || '-',
|
|
},
|
|
{
|
|
title: 'Actions',
|
|
key: 'actions',
|
|
render: (_, record) => (
|
|
<Space>
|
|
<Tooltip title="Download">
|
|
<Button icon={<DownloadOutlined />} onClick={() => handleDownload(record.name)} />
|
|
</Tooltip>
|
|
<Tooltip title="Open in editor">
|
|
<Button icon={<EditOutlined />} onClick={() => openInEditorFromServer(record.name)} />
|
|
</Tooltip>
|
|
<Tooltip title="Enable">
|
|
<Button
|
|
icon={<PlayCircleOutlined />}
|
|
onClick={() => {
|
|
setEnableTarget(record.name);
|
|
setEnableModalVisible(true);
|
|
}}
|
|
/>
|
|
</Tooltip>
|
|
<Tooltip title="Disable">
|
|
<Button icon={<PauseCircleOutlined />} onClick={() => handleDisable(record.name)} />
|
|
</Tooltip>
|
|
<Popconfirm title={`Delete ${record.name}?`} onConfirm={() => handleDelete(record.name)}>
|
|
<Button danger icon={<DeleteOutlined />} />
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div>
|
|
<Row justify="space-between" style={{ marginBottom: 12 }}>
|
|
<Col>
|
|
<Space>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadModalVisible(true)}>
|
|
Upload / New
|
|
</Button>
|
|
<Button onClick={refreshList}>Refresh</Button>
|
|
<Button onClick={refreshStatuses}>{fetchingStatuses ? <Spin size="small" /> : 'Statuses'}</Button>
|
|
</Space>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Table rowKey="name" columns={columns} dataSource={scripts} loading={loading} pagination={{ pageSize: 10 }} />
|
|
|
|
{/* Upload Modal */}
|
|
<Modal
|
|
open={uploadModalVisible}
|
|
title="Upload or create script"
|
|
onCancel={() => {
|
|
setUploadModalVisible(false);
|
|
form.resetFields();
|
|
}}
|
|
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>
|
|
|
|
<Form.Item label="Script file (optional if using editor below)">
|
|
<Form.Item name="scriptFile" noStyle>
|
|
<Upload beforeUpload={() => false} maxCount={1}>
|
|
<Button icon={<UploadOutlined />}>Choose script file</Button>
|
|
</Upload>
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="requirements.txt (optional)">
|
|
<Form.Item name="requirementsFile" noStyle>
|
|
<Upload beforeUpload={() => false} maxCount={1}>
|
|
<Button icon={<UploadOutlined />}>Choose requirements</Button>
|
|
</Upload>
|
|
</Form.Item>
|
|
</Form.Item>
|
|
|
|
<Form.Item label="Or paste/edit script (editor)">
|
|
<PythonEditor value={editorValue} onChange={setEditorValue} height={220} />
|
|
</Form.Item>
|
|
|
|
<Form.Item>
|
|
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
<Button
|
|
onClick={() => {
|
|
setUploadModalVisible(false);
|
|
form.resetFields();
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="primary" htmlType="submit">
|
|
Upload
|
|
</Button>
|
|
</Space>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* Editor Modal (open existing script) */}
|
|
<Modal
|
|
open={editorModalVisible}
|
|
title={currentEditingName ? `Editing — ${currentEditingName}` : 'Editor'}
|
|
onCancel={() => setEditorModalVisible(false)}
|
|
width={900}
|
|
footer={
|
|
<Space>
|
|
<Button onClick={() => setEditorModalVisible(false)}>Close</Button>
|
|
<Button
|
|
type="default"
|
|
onClick={() => {
|
|
// Save as new name
|
|
Modal.confirm({
|
|
title: 'Save as',
|
|
content: (
|
|
<Form
|
|
layout="vertical"
|
|
onFinish={(values) => {
|
|
handleSaveFromEditorAs(values.name);
|
|
Modal.destroyAll();
|
|
}}
|
|
>
|
|
<Form.Item name="name" label="Name" rules={[{ required: true }]}>
|
|
<Input placeholder="new_name.py" />
|
|
</Form.Item>
|
|
<Form.Item>
|
|
<Button htmlType="submit" type="primary">
|
|
Save
|
|
</Button>
|
|
</Form.Item>
|
|
</Form>
|
|
),
|
|
icon: null,
|
|
okButtonProps: { style: { display: 'none' } },
|
|
cancelButtonProps: { style: { display: 'none' } },
|
|
});
|
|
}}
|
|
>
|
|
Save as...
|
|
</Button>
|
|
<Button
|
|
type="primary"
|
|
onClick={() => {
|
|
// overwrite current
|
|
if (!currentEditingName) {
|
|
notification.warning({ message: 'No filename to overwrite' });
|
|
return;
|
|
}
|
|
handleSaveFromEditorAs(currentEditingName);
|
|
}}
|
|
>
|
|
Save
|
|
</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
<PythonEditor value={editorValue} onChange={setEditorValue} height={520} />
|
|
</Modal>
|
|
|
|
{/* Enable Modal */}
|
|
<Modal
|
|
open={enableModalVisible}
|
|
title={`Enable ${enableTarget ?? ''}`}
|
|
onCancel={() => {
|
|
setEnableModalVisible(false);
|
|
setEnableTarget(null);
|
|
}}
|
|
footer={null}
|
|
>
|
|
<Form form={enableForm} layout="vertical" onFinish={handleEnableSubmit}>
|
|
<Form.Item name="qnum" label="Queue number (qnum)" rules={[{ required: true }]}>
|
|
<Input placeholder="e.g. 0" type="number" />
|
|
</Form.Item>
|
|
<Form.Item name="service_name" label="Service name (optional)">
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="extra_args" label="Extra args (optional)">
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name="enable_at_boot" valuePropName="checked">
|
|
<Input type="checkbox" />
|
|
</Form.Item>
|
|
|
|
<Form.Item>
|
|
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
|
<Button
|
|
onClick={() => {
|
|
setEnableModalVisible(false);
|
|
enableForm.resetFields();
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button type="primary" htmlType="submit">
|
|
Enable
|
|
</Button>
|
|
</Space>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|