1080 lines
35 KiB
TypeScript
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>
|
|
);
|
|
}
|