Files
mitm-webserver/frontend/src/components/ScriptManager.tsx
malmert 76256d56f2
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 0s
Build and Deploy MITM Webserver / build (push) Successful in 11s
test packet rewrite dns example
2026-04-17 22:11:15 +02:00

1080 lines
35 KiB
TypeScript

import {
DeleteOutlined,
DownloadOutlined,
EditOutlined,
EyeOutlined,
FileAddOutlined,
PauseCircleOutlined,
PlayCircleOutlined,
PlusOutlined,
ReloadOutlined,
UploadOutlined,
} from '@ant-design/icons';
import {
Badge,
Button,
Checkbox,
Col,
Form,
Input,
Modal,
notification,
Popconfirm,
Row,
Space,
Spin,
Table,
Tooltip,
Typography,
Upload,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { UploadFile } from 'antd/es/upload/interface';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
deleteRequirements,
deleteScript,
disableScript,
downloadRequirements,
downloadScript,
enableScript,
fetchScriptsAll,
uploadRequirements,
uploadScript,
} from '../api/apiClient';
import PythonEditor from '../components/PythonEditor';
import {
DeleteResult,
EnableRequest,
OperationResult,
ScriptUploadResponse,
ScriptWithStatus,
} from '../types/scripting';
const { Paragraph } = Typography;
/**
* ScriptsManager
*
* - Upload modal includes optional inline requirements editor (checkbox toggle)
* - Upload modal width increased
* - Table action buttons are icon-only with Tooltip
* - Expands nested rows by default
* - Focus helper attempts to put caret at end inside editors when modals open
* - New: Add requirements UI for scripts that have no requirements
*/
export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (code: string) => void }) {
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 [editorReadOnly, setEditorReadOnly] = useState(false);
const [useInlineReqEditor, setUseInlineReqEditor] = useState(false);
const [inlineReqValue, setInlineReqValue] = useState<string>('');
const [pipModalVisible, setPipModalVisible] = useState(false);
const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null);
const [reqModalVisible, setReqModalVisible] = useState(false);
const [reqEditorValue, setReqEditorValue] = useState<string>('');
const [reqEditingName, setReqEditingName] = useState<string | null>(null);
const [addReqModalVisible, setAddReqModalVisible] = useState(false);
const [addReqTarget, setAddReqTarget] = useState<string | null>(null);
const [addReqUseInline, setAddReqUseInline] = useState(false);
const [addReqInlineValue, setAddReqInlineValue] = useState('');
const [addReqFile, setAddReqFile] = useState<File | null>(null);
const [enableModalVisible, setEnableModalVisible] = useState(false);
const [enableTarget, setEnableTarget] = useState<string | null>(null);
const [form] = Form.useForm();
const [enableForm] = Form.useForm();
const refreshAll = useCallback(async () => {
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);
}
}, []);
const handleManualRefresh = useCallback(async () => {
setLoading(true);
try {
const all = await fetchScriptsAll();
setScripts(all || []);
const count = (all || []).length;
notification.success({
message: 'Refresh complete',
description: `Loaded ${count} script${count === 1 ? '' : 's'}.`,
});
} catch (err: any) {
notification.error({
message: 'Failed to fetch scripts',
description: err?.message ?? String(err),
});
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refreshAll();
}, [refreshAll]);
const getMappingsForScript = useCallback(
(scriptName: string) => {
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,
}));
},
[scripts],
);
const focusEditorInModal = useCallback((modalSelector = '.ant-modal') => {
setTimeout(() => {
try {
const modal = document.querySelector(modalSelector);
if (!modal) return;
const ta = modal.querySelector('.python-editor textarea') as HTMLTextAreaElement | null;
if (ta) {
ta.focus();
const val = ta.value ?? '';
ta.setSelectionRange(val.length, val.length);
}
} catch {}
}, 80);
}, []);
useEffect(() => {
if (editorModalVisible) focusEditorInModal('.ant-modal');
}, [editorModalVisible, focusEditorInModal]);
useEffect(() => {
if (uploadModalVisible) focusEditorInModal('.ant-modal');
}, [uploadModalVisible, focusEditorInModal]);
useEffect(() => {
if (reqModalVisible) focusEditorInModal('.ant-modal');
}, [reqModalVisible, focusEditorInModal]);
useEffect(() => {
if (addReqModalVisible) focusEditorInModal('.ant-modal');
}, [addReqModalVisible, focusEditorInModal]);
const showPipIfPresent = useCallback(
(resp?: ScriptUploadResponse | { pip?: { stdout?: string; stderr?: string } } | null) => {
const pip = (resp as any)?.pip;
if (pip && (pip.stdout || pip.stderr)) {
setPipOutput(pip);
setPipModalVisible(true);
}
},
[],
);
const handleDownload = useCallback(async (name: string) => {
try {
const blob = await downloadScript(name);
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) });
}
}, []);
const handleDownloadRequirements = useCallback(async (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) });
}
}, []);
const handleEditRequirements = useCallback(async (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) });
}
}, []);
const handleSaveRequirements = useCallback(
async (name: string, content: string) => {
try {
const resp = await uploadRequirements(name, new Blob([content], { type: 'text/plain' }));
showPipIfPresent(resp);
setReqModalVisible(false);
setReqEditingName(null);
setReqEditorValue('');
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Failed to upload requirements', description: err?.message ?? String(err) });
}
},
[refreshAll, showPipIfPresent],
);
const handleDeleteRequirements = useCallback(
(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 as any).errors && (res as any).errors.length) {
Modal.info({
title: 'Delete requirements returned errors',
content: <pre style={{ maxHeight: 240, overflow: 'auto' }}>{(res as any).errors.join('\n')}</pre>,
});
}
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Failed to delete requirements', description: err?.message ?? String(err) });
}
},
});
},
[refreshAll],
);
const handleDelete = useCallback(
async (name: string) => {
try {
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>
),
});
}
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Delete failed', description: err?.message ?? String(err) });
}
},
[refreshAll],
);
const handleDisableInstance = useCallback(
(scriptName: string, mapping: any) => {
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 {
await disableScript(scriptName, qnum);
notification.success({ message: 'Disabled', description: `${mapping.service} disabled` });
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Disable failed', description: err?.message ?? String(err) });
}
},
});
},
[refreshAll],
);
const handleUpload = useCallback(
async (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 || new Blob([editorValue], { type: 'text/x-python' });
let req: File | Blob | null = null;
if (requirementsFile) {
req = requirementsFile;
} else if (useInlineReqEditor && inlineReqValue && inlineReqValue.trim() !== '') {
req = new Blob([inlineReqValue], { type: 'text/plain' });
} else {
req = null;
}
try {
const resp: ScriptUploadResponse = await uploadScript({
name,
script,
requirements: req,
});
notification.success({ message: 'Uploaded', description: `${name} uploaded` });
showPipIfPresent(resp);
setUploadModalVisible(false);
form.resetFields();
setCurrentEditingName(null);
setUseInlineReqEditor(false);
setInlineReqValue('');
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Upload failed', description: err?.message ?? String(err) });
}
},
[editorValue, currentEditingName, form, inlineReqValue, refreshAll, showPipIfPresent, useInlineReqEditor],
);
const openInEditorFromServer = useCallback(
async (name: string) => {
try {
const blob = await downloadScript(name);
const text = await blob.text();
setEditorValue(text);
setEditorReadOnly(false);
setEditorModalVisible(true);
setCurrentEditingName(name);
if (onOpenInEditor) onOpenInEditor(text);
} catch (err: any) {
notification.error({ message: 'Failed to open', description: err?.message ?? String(err) });
}
},
[onOpenInEditor],
);
const openReadOnlyViewerFromServer = useCallback(async (name: string) => {
try {
const blob = await downloadScript(name);
const text = await blob.text();
setEditorValue(text);
setEditorReadOnly(true);
setEditorModalVisible(true);
setCurrentEditingName(name);
} catch (err: any) {
notification.error({ message: 'Failed to open', description: err?.message ?? String(err) });
}
}, []);
const handleSaveFromEditorAs = useCallback(
async (name: string) => {
try {
const resp: ScriptUploadResponse = await uploadScript({
name,
script: new Blob([editorValue], { type: 'text/x-python' }),
});
notification.success({ message: 'Saved', description: `${name} uploaded` });
showPipIfPresent(resp);
setEditorModalVisible(false);
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Save failed', description: err?.message ?? String(err) });
}
},
[editorValue, refreshAll, showPipIfPresent],
);
const handleEnableSubmit = useCallback(
async (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 {
const res: OperationResult = await enableScript(enableTarget, req);
notification.success({ message: 'Enabled', description: `${enableTarget} enabled as ${res.service}` });
setEnableModalVisible(false);
setEnableTarget(null);
enableForm.resetFields();
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Enable failed', description: err?.message ?? String(err) });
}
},
[enableTarget, enableForm, refreshAll],
);
const handleDisable = useCallback(
async (name: string) => {
try {
const qnum = 0;
const res: OperationResult = await disableScript(name, qnum);
notification.success({ message: 'Disabled', description: `${name} disabled (service ${res.service})` });
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Disable failed', description: err?.message ?? String(err) });
}
},
[refreshAll],
);
const normFile = (e: any) => {
if (!e) return undefined;
const list: UploadFile[] = e.fileList || [];
if (list.length === 0) return undefined;
const last = list[list.length - 1];
return (last as any).originFileObj ?? last;
};
function IconButtonTooltip({
title,
onClick,
icon,
danger,
disabled,
}: {
title: string;
onClick?: () => void;
icon: React.ReactNode;
danger?: boolean;
disabled?: boolean;
}) {
return (
<Tooltip title={title}>
<Button size="small" icon={icon} onClick={onClick} danger={danger} disabled={disabled} />
</Tooltip>
);
}
const openAddRequirements = useCallback((scriptName: string) => {
setAddReqTarget(scriptName);
setAddReqUseInline(false);
setAddReqInlineValue('');
setAddReqFile(null);
setAddReqModalVisible(true);
}, []);
const renderStatus = useCallback((isActive: boolean) => {
return <Badge color={isActive ? '#52c41a' : '#ff4d4f'} text={isActive ? 'Active' : 'Inactive'} />;
}, []);
const isScriptActive = useCallback(
(record: ScriptWithStatus) => record.mappings.some((mapping) => mapping.active),
[],
);
const saveAddRequirements = useCallback(async () => {
if (!addReqTarget) return;
let payload: File | Blob | null = null;
if (addReqFile) {
payload = addReqFile;
} else if (addReqUseInline && addReqInlineValue.trim() !== '') {
payload = new Blob([addReqInlineValue], { type: 'text/plain' });
} else {
notification.warning({
message: 'No requirements provided',
description: 'Upload a file or enter inline content.',
});
return;
}
try {
const resp = await uploadRequirements(addReqTarget, payload);
notification.success({ message: 'requirements uploaded', description: `${addReqTarget} updated` });
showPipIfPresent(resp);
setAddReqModalVisible(false);
setAddReqTarget(null);
setAddReqInlineValue('');
setAddReqFile(null);
await refreshAll();
} catch (err: any) {
notification.error({ message: 'Failed to upload requirements', description: err?.message ?? String(err) });
}
}, [addReqFile, addReqInlineValue, addReqTarget, refreshAll, showPipIfPresent]);
const onAddReqUploadChange = useCallback((info: any) => {
const list: UploadFile[] = info.fileList || [];
if (list.length === 0) {
setAddReqFile(null);
return;
}
const last = list[list.length - 1] as any;
setAddReqFile(last.originFileObj ?? last);
}, []);
const columns: ColumnsType<ScriptWithStatus> = useMemo(
() => [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
render: (_: unknown, record: ScriptWithStatus) => (
<Space>
<code>{record.name}</code>
</Space>
),
},
{
title: 'Status',
key: 'status',
width: 120,
render: (_: unknown, record: ScriptWithStatus) => renderStatus(isScriptActive(record)),
},
{
title: 'Path',
dataIndex: 'path',
key: 'path',
render: (p) => p || '-',
},
{
title: 'Requirements',
key: 'requirements',
render: (_: unknown, record: ScriptWithStatus) =>
record.requirements_exists ? (
<Space>
<IconButtonTooltip
title="Download requirements"
onClick={() => handleDownloadRequirements(record.name)}
icon={<DownloadOutlined />}
/>
{!record.requirements_is_protected_example ? (
<IconButtonTooltip
title="Edit requirements"
onClick={() => handleEditRequirements(record.name)}
icon={<EditOutlined />}
/>
) : null}
{!record.requirements_is_protected_example ? (
<IconButtonTooltip
title="Delete requirements"
danger
onClick={() => handleDeleteRequirements(record.name)}
icon={<DeleteOutlined />}
/>
) : null}
</Space>
) : (
<Space>
{!record.is_protected_example ? (
<IconButtonTooltip
title="Add requirements"
onClick={() => openAddRequirements(record.name)}
icon={<FileAddOutlined />}
/>
) : (
<span style={{ color: '#8c8c8c' }}>Protected</span>
)}
</Space>
),
},
{
title: 'Actions',
key: 'actions',
width: 240,
render: (_: unknown, record: ScriptWithStatus) => (
<Space>
<IconButtonTooltip
title="Download script"
onClick={() => handleDownload(record.name)}
icon={<DownloadOutlined />}
/>
{record.is_protected_example ? (
<IconButtonTooltip
title="View script"
onClick={() => openReadOnlyViewerFromServer(record.name)}
icon={<EyeOutlined />}
/>
) : null}
{!record.is_protected_example ? (
<IconButtonTooltip
title="Open in editor"
onClick={() => openInEditorFromServer(record.name)}
icon={<EditOutlined />}
/>
) : null}
<IconButtonTooltip
title="Enable"
onClick={() => {
setEnableTarget(record.name);
setEnableModalVisible(true);
}}
icon={<PlayCircleOutlined />}
/>
{!record.is_protected_example ? (
<Popconfirm title={`Delete ${record.name}?`} onConfirm={() => handleDelete(record.name)}>
<Tooltip title="Delete script">
<Button danger size="small" icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm>
) : null}
</Space>
),
},
],
[
handleDelete,
handleDeleteRequirements,
handleDownload,
handleDownloadRequirements,
handleEditRequirements,
isScriptActive,
openAddRequirements,
openReadOnlyViewerFromServer,
openInEditorFromServer,
renderStatus,
],
);
const nestedColumns = useMemo(
() => [
{
title: 'Service',
dataIndex: ['parsed', 'service'],
key: 'service',
render: (_: any, r: any) => <code>{r.service}</code>,
},
{
title: 'qnum',
dataIndex: ['parsed', 'qnum'],
key: 'qnum',
render: (q: number | null) => (q == null ? '-' : q),
},
{
title: 'Status',
dataIndex: 'active',
key: 'active',
render: (active: boolean) => renderStatus(active),
},
{ 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: any) => (
<Space>
<IconButtonTooltip
title="Disable instance"
danger
onClick={() => handleDisableInstance(r.parsed.name ?? r.parsed.service ?? '', r)}
icon={<PauseCircleOutlined />}
disabled={typeof r.parsed.qnum !== 'number'}
/>
</Space>
),
},
],
[handleDisableInstance, renderStatus],
);
const expandable = useMemo(
() => ({
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>;
}
return (
<Table
columns={nestedColumns}
dataSource={mappings}
pagination={false}
rowKey={(r: any) => r.service}
size="small"
/>
);
},
rowExpandable: (record: ScriptWithStatus) => {
const mappings = getMappingsForScript(record.name);
return mappings.length > 0;
},
defaultExpandAllRows: true,
expandRowByClick: false,
}),
[getMappingsForScript, nestedColumns],
);
return (
<div>
<Row justify="space-between" style={{ marginBottom: 12 }}>
<Col span={24} style={{ display: 'flex', justifyContent: 'space-between' }}>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadModalVisible(true)}>
Upload / New
</Button>
<Button onClick={handleManualRefresh} icon={loading ? <Spin size="small" /> : <ReloadOutlined />}>
Refresh
</Button>
</Col>
</Row>
<Table
rowKey="name"
columns={columns}
dataSource={scripts}
loading={loading}
pagination={{ pageSize: 10 }}
expandable={expandable}
/>
<Modal
open={uploadModalVisible}
title="Upload or create script"
onCancel={() => {
setUploadModalVisible(false);
form.resetFields();
setUseInlineReqEditor(false);
setInlineReqValue('');
}}
footer={null}
width={900}
>
<Form form={form} layout="vertical" onFinish={handleUpload}>
<Form.Item name="name" label="Script name" rules={[{ required: true }]}>
<Input placeholder="my_script" />
</Form.Item>
<Form.Item label="Script file (optional: prefer file if provided)">
<Form.Item name="scriptFile" valuePropName="file" getValueFromEvent={normFile} noStyle>
<Upload beforeUpload={() => false} maxCount={1} accept=".py">
<Button icon={<UploadOutlined />}>Choose script file</Button>
</Upload>
</Form.Item>
<div style={{ marginTop: 8, color: '#666' }}>Or paste your script in the editor below.</div>
</Form.Item>
<Form.Item label="requirements.txt (optional)">
<Space direction="vertical" style={{ width: '100%' }}>
<Form.Item name="requirementsFile" valuePropName="file" getValueFromEvent={normFile} noStyle>
<Upload beforeUpload={() => false} maxCount={1} accept=".txt">
<Button icon={<UploadOutlined />}>Choose requirements file</Button>
</Upload>
</Form.Item>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Checkbox checked={useInlineReqEditor} onChange={(e) => setUseInlineReqEditor(e.target.checked)}>
Edit requirements inline
</Checkbox>
<div style={{ color: '#666' }}>
If both a file and inline content are present, file takes precedence.
</div>
</div>
{useInlineReqEditor && (
<div style={{ marginTop: 8 }}>
<PythonEditor value={inlineReqValue} onChange={setInlineReqValue} height={180} wrap />
</div>
)}
</Space>
</Form.Item>
<Form.Item label="Or paste/edit script (editor)">
<PythonEditor value={editorValue} onChange={setEditorValue} height={300} />
</Form.Item>
<Form.Item>
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
onClick={() => {
setUploadModalVisible(false);
form.resetFields();
setUseInlineReqEditor(false);
setInlineReqValue('');
}}
>
Cancel
</Button>
<Button type="primary" htmlType="submit">
Upload
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
open={editorModalVisible}
title={
currentEditingName
? `${editorReadOnly ? 'Viewing' : 'Editing'} — ${currentEditingName}`
: editorReadOnly
? 'Viewer'
: 'Editor'
}
onCancel={() => {
setEditorModalVisible(false);
setEditorReadOnly(false);
}}
width={900}
footer={
editorReadOnly ? (
<Button
onClick={() => {
setEditorModalVisible(false);
setEditorReadOnly(false);
}}
>
Close
</Button>
) : (
<Space>
<Button
onClick={() => {
setEditorModalVisible(false);
setEditorReadOnly(false);
}}
>
Close
</Button>
<Button
type="default"
onClick={() =>
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={() => {
if (!currentEditingName) {
notification.warning({ message: 'No filename to overwrite' });
return;
}
handleSaveFromEditorAs(currentEditingName);
}}
>
Save
</Button>
</Space>
)
}
>
<PythonEditor value={editorValue} onChange={setEditorValue} height={520} readOnly={editorReadOnly} />
</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>
<Modal
open={addReqModalVisible}
title={addReqTarget ? `Add requirements — ${addReqTarget}` : 'Add requirements'}
onCancel={() => {
setAddReqModalVisible(false);
setAddReqTarget(null);
setAddReqInlineValue('');
setAddReqFile(null);
setAddReqUseInline(false);
}}
footer={
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
onClick={() => {
setAddReqModalVisible(false);
setAddReqTarget(null);
setAddReqInlineValue('');
setAddReqFile(null);
setAddReqUseInline(false);
}}
>
Cancel
</Button>
<Button type="primary" onClick={saveAddRequirements}>
Save requirements
</Button>
</Space>
}
width={700}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={{ marginBottom: 8 }}>Upload a requirements.txt file (preferred)</div>
<Upload beforeUpload={() => false} maxCount={1} accept=".txt" onChange={onAddReqUploadChange}>
<Button icon={<UploadOutlined />}>Choose requirements file</Button>
</Upload>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Checkbox checked={addReqUseInline} onChange={(e) => setAddReqUseInline(e.target.checked)}>
Edit requirements inline
</Checkbox>
<div style={{ color: '#666' }}>If both a file and inline content are present, file takes precedence.</div>
</div>
{addReqUseInline && (
<div>
<PythonEditor value={addReqInlineValue} onChange={setAddReqInlineValue} height={220} wrap />
</div>
)}
</div>
</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>
<Modal
open={enableModalVisible}
title={`Enable ${enableTarget ?? ''}`}
onCancel={() => {
setEnableModalVisible(false);
setEnableTarget(null);
}}
footer={
<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>
}
/>
</div>
);
}