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([]); const [loading, setLoading] = useState(false); const [uploadModalVisible, setUploadModalVisible] = useState(false); const [editorModalVisible, setEditorModalVisible] = useState(false); const [editorValue, setEditorValue] = useState(''); const [currentEditingName, setCurrentEditingName] = useState(null); const [editorReadOnly, setEditorReadOnly] = useState(false); const [useInlineReqEditor, setUseInlineReqEditor] = useState(false); const [inlineReqValue, setInlineReqValue] = useState(''); const [pipModalVisible, setPipModalVisible] = useState(false); const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null); const [reqModalVisible, setReqModalVisible] = useState(false); const [reqEditorValue, setReqEditorValue] = useState(''); const [reqEditingName, setReqEditingName] = useState(null); const [addReqModalVisible, setAddReqModalVisible] = useState(false); const [addReqTarget, setAddReqTarget] = useState(null); const [addReqUseInline, setAddReqUseInline] = useState(false); const [addReqInlineValue, setAddReqInlineValue] = useState(''); const [addReqFile, setAddReqFile] = useState(null); const [enableModalVisible, setEnableModalVisible] = useState(false); const [enableTarget, setEnableTarget] = useState(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:
{(res as any).errors.join('\n')}
, }); } 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: (
Errors occurred while deleting resources:
{res.errors.join('\n')}
), }); } 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 (