api fix and script fe improv
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s

This commit is contained in:
2026-03-01 11:57:54 +01:00
parent 60634a77eb
commit 7c08aed33a
3 changed files with 661 additions and 509 deletions

View File

@@ -494,10 +494,6 @@ async def upload_script(
logger.warning("Invalid name provided: %s", name) logger.warning("Invalid name provided: %s", name)
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
if not script.filename.endswith(".py"):
logger.warning("Upload rejected: script not .py (name=%s original=%s)", name, script.filename)
raise HTTPException(status_code=400, detail="only .py scripts allowed")
spath = script_path_for(name) spath = script_path_for(name)
if os.path.exists(spath): if os.path.exists(spath):
logger.warning("Upload rejected: script with name already exists: %s", name) logger.warning("Upload rejected: script with name already exists: %s", name)

View File

@@ -1,76 +1,111 @@
// src/components/PythonEditor.tsx
import Prism from 'prismjs'; import Prism from 'prismjs';
import 'prismjs/components/prism-python'; import 'prismjs/components/prism-python';
import 'prismjs/themes/prism.css'; import 'prismjs/themes/prism.css';
import { useCallback, useMemo } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import Editor from 'react-simple-code-editor';
// Props for the editor
export type PythonEditorProps = { export type PythonEditorProps = {
value: string; value: string;
onChange: (code: string) => void; onChange: (code: string) => void;
readOnly?: boolean; readOnly?: boolean;
className?: string; height?: string | number;
height?: string | number; // e.g. '200px' or 200 wrap?: boolean;
wrap?: boolean; // if true, long lines will wrap; default false (no wrap) fontSize?: number;
tabSize?: number;
}; };
const DEFAULT_FONT =
'"JetBrains Mono", "Fira Code", "Source Code Pro", ui-monospace, SFMono-Regular, Menlo, Monaco, monospace';
export default function PythonEditor({ export default function PythonEditor({
value, value,
onChange, onChange,
readOnly = false, readOnly = false,
height = 480, height = 480,
className = '',
wrap = false, wrap = false,
fontSize = 14,
tabSize = 4,
}: PythonEditorProps) { }: PythonEditorProps) {
const highlight = useCallback((code: string) => { const textareaRef = useRef<HTMLTextAreaElement | null>(null);
try { const preRef = useRef<HTMLPreElement | null>(null);
return Prism.highlight(code, Prism.languages.python, 'python');
} catch (e) {
return code;
}
}, []);
// container style: fixed height + scrolling
const containerStyle = useMemo(() => {
const h = typeof height === 'number' ? `${height}px` : height; const h = typeof height === 'number' ? `${height}px` : height;
return {
height: h,
maxHeight: h,
overflow: 'auto' as const, // enable scrolling
position: 'relative' as const,
borderRadius: 6,
border: '1px solid rgba(0,0,0,0.06)',
background: 'var(--code-bg, white)',
};
}, [height]);
// editor style: fill container and use monospace font const lineHeight = Math.round(fontSize * 1.6);
const editorStyle = useMemo(
// Highlight whenever value changes
useEffect(() => {
if (preRef.current) {
preRef.current.innerHTML = Prism.highlight(value, Prism.languages.python, 'python');
}
}, [value]);
// Scroll sync
const handleScroll = () => {
if (!textareaRef.current || !preRef.current) return;
preRef.current.scrollTop = textareaRef.current.scrollTop;
preRef.current.scrollLeft = textareaRef.current.scrollLeft;
};
const sharedStyle: React.CSSProperties = useMemo(
() => ({ () => ({
fontFamily: '"JetBrains Mono", "Fira Code", monospace', fontFamily: DEFAULT_FONT,
fontSize: 14, fontSize: `${fontSize}px`,
// make editor fill the container (pre/textarea inside will take up height) lineHeight: `${lineHeight}px`,
minHeight: '100%', tabSize,
boxSizing: 'border-box' as const, whiteSpace: wrap ? 'pre-wrap' : 'pre',
wordBreak: 'break-word',
padding: 12, padding: 12,
outline: 'none', boxSizing: 'border-box',
whiteSpace: wrap ? 'pre-wrap' : 'pre', // pre-wrap if wrap requested
wordBreak: 'break-word' as const,
}), }),
[wrap], [fontSize, lineHeight, tabSize, wrap],
); );
return ( return (
<div className={`python-editor ${className}`} style={containerStyle}> <div
<Editor style={{
position: 'relative',
height: h,
borderRadius: 6,
border: '1px solid rgba(0,0,0,0.06)',
overflow: 'hidden',
}}
>
{/* Highlight layer */}
<pre
ref={preRef}
aria-hidden="true"
className="language-python"
style={{
...sharedStyle,
margin: 0,
position: 'absolute',
inset: 0,
overflow: 'auto',
pointerEvents: 'none',
}}
/>
{/* Editable layer */}
<textarea
ref={textareaRef}
value={value} value={value}
onValueChange={(code) => onChange(code)}
highlight={highlight}
padding={0} // padding handled by editorStyle
preClassName="language-python"
textareaId="python-editor"
style={editorStyle}
readOnly={readOnly} readOnly={readOnly}
onChange={(e) => onChange(e.target.value)}
onScroll={handleScroll}
spellCheck={false}
style={{
...sharedStyle,
position: 'absolute',
inset: 0,
resize: 'none',
border: 'none',
outline: 'none',
background: 'transparent',
color: 'transparent', // hide textarea text
caretColor: 'black', // show caret
overflow: 'auto',
}}
/> />
</div> </div>
); );

View File

@@ -1,3 +1,4 @@
// src/pages/ScriptsManager.tsx
import { import {
DeleteOutlined, DeleteOutlined,
DownloadOutlined, DownloadOutlined,
@@ -5,10 +6,12 @@ import {
PauseCircleOutlined, PauseCircleOutlined,
PlayCircleOutlined, PlayCircleOutlined,
PlusOutlined, PlusOutlined,
ReloadOutlined,
UploadOutlined, UploadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { import {
Button, Button,
Checkbox,
Col, Col,
Form, Form,
Input, Input,
@@ -24,7 +27,8 @@ import {
Upload, Upload,
} from 'antd'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { useEffect, useState } from 'react'; import type { UploadFile } from 'antd/es/upload/interface';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
deleteRequirements, deleteRequirements,
@@ -34,7 +38,6 @@ import {
downloadScript, downloadScript,
enableScript, enableScript,
fetchScriptsAll, fetchScriptsAll,
listScripts,
uploadRequirements, uploadRequirements,
uploadScript, uploadScript,
} from '../api/apiClient'; } from '../api/apiClient';
@@ -43,104 +46,80 @@ import {
DeleteResult, DeleteResult,
EnableRequest, EnableRequest,
OperationResult, OperationResult,
ScriptInfo,
ScriptUploadResponse, ScriptUploadResponse,
ScriptWithStatus, ScriptWithStatus,
} from '../types/scripting'; } from '../types/scripting';
const { Paragraph } = Typography; const { Paragraph } = Typography;
type Props = { /**
onOpenInEditor?: (code: string) => void; * 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
*/
type UnitParsedLocal = { export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (code: string) => void }) {
service: string; // --------------------
exec_start?: string | null; // State
name?: string | null; // --------------------
script_path?: string | null;
qnum?: number | null;
extra?: string | null;
};
type UnitMapping = {
service: string;
parsed: UnitParsedLocal;
active: boolean;
};
export default function ScriptsManager({ onOpenInEditor }: Props) {
// now scripts holds ScriptWithStatus entries (name,path,mappings,requirements_exists)
const [scripts, setScripts] = useState<ScriptWithStatus[]>([]); const [scripts, setScripts] = useState<ScriptWithStatus[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// modals & editor values
const [uploadModalVisible, setUploadModalVisible] = useState(false); const [uploadModalVisible, setUploadModalVisible] = useState(false);
const [editorModalVisible, setEditorModalVisible] = useState(false); const [editorModalVisible, setEditorModalVisible] = useState(false);
const [editorValue, setEditorValue] = useState<string>(''); const [editorValue, setEditorValue] = useState<string>('');
const [currentEditingName, setCurrentEditingName] = useState<string | null>(null); const [currentEditingName, setCurrentEditingName] = useState<string | null>(null);
const [fetchingStatuses, setFetchingStatuses] = useState(false);
// inline requirements editor toggle + value for upload modal
const [useInlineReqEditor, setUseInlineReqEditor] = useState(false);
const [inlineReqValue, setInlineReqValue] = useState<string>('');
// pip output modal // pip output modal
const [pipModalVisible, setPipModalVisible] = useState(false); const [pipModalVisible, setPipModalVisible] = useState(false);
const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null); const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null);
// requirements editor modal // requirements editor modal (existing per-script editor)
const [reqModalVisible, setReqModalVisible] = useState(false); const [reqModalVisible, setReqModalVisible] = useState(false);
const [reqEditorValue, setReqEditorValue] = useState<string>(''); const [reqEditorValue, setReqEditorValue] = useState<string>('');
const [reqEditingName, setReqEditingName] = useState<string | null>(null); const [reqEditingName, setReqEditingName] = useState<string | null>(null);
// enable modal
const [enableModalVisible, setEnableModalVisible] = useState(false);
const [enableTarget, setEnableTarget] = useState<string | null>(null);
// forms
const [form] = Form.useForm(); const [form] = Form.useForm();
const [enableForm] = Form.useForm(); const [enableForm] = Form.useForm();
useEffect(() => { // --------------------
refreshAll(); // Data fetch
// --------------------
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);
}
}, []); }, []);
async function refreshAll() { useEffect(() => {
setLoading(true); refreshAll();
try { }, [refreshAll]);
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); // Helpers
try { // --------------------
const data = await listScripts(); const getMappingsForScript = useCallback(
// map listScripts (basic) onto current scripts while preserving existing mappings where possible (scriptName: string) => {
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 {
setLoading(false);
}
}
async function refreshStatuses() {
setFetchingStatuses(true);
try {
// 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) });
} finally {
setFetchingStatuses(false);
}
}
// Helper to get mappings for a script (already available on the script object)
const getMappingsForScript = (scriptName: string): UnitMapping[] => {
const s = scripts.find((x) => x.name === scriptName); const s = scripts.find((x) => x.name === scriptName);
if (!s || !s.mappings) return []; if (!s || !s.mappings) return [];
return s.mappings.map((m) => ({ return s.mappings.map((m) => ({
@@ -155,11 +134,58 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
}, },
active: !!m.active, active: !!m.active,
})); }));
}; },
[scripts],
);
async function handleDownload(name: string) { // Focus helper: tries to find a textarea inside the modal and put caret at the end.
const focusEditorInModal = useCallback((modalSelector = '.ant-modal') => {
setTimeout(() => {
try { try {
const blob = await downloadScript(name); // Blob 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 {
// ignore
}
}, 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]);
// show pip modal if pip output is present
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);
}
},
[],
);
// --------------------
// API Handlers
// --------------------
const handleDownload = useCallback(async (name: string) => {
try {
const blob = await downloadScript(name);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
@@ -170,12 +196,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
notification.success({ message: 'Download started' }); notification.success({ message: 'Download started' });
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Download failed', description: err?.message || String(err) }); notification.error({ message: 'Download failed', description: err?.message ?? String(err) });
}
} }
}, []);
// requirements download const handleDownloadRequirements = useCallback(async (name: string) => {
async function handleDownloadRequirements(name: string) {
try { try {
const blob = await downloadRequirements(name); const blob = await downloadRequirements(name);
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
@@ -188,12 +213,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
notification.success({ message: 'requirements.txt download started' }); notification.success({ message: 'requirements.txt download started' });
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Download failed', description: err?.message || String(err) }); notification.error({ message: 'Download failed', description: err?.message ?? String(err) });
}
} }
}, []);
// Edit requirements (download and open editor) const handleEditRequirements = useCallback(async (name: string) => {
async function handleEditRequirements(name: string) {
try { try {
const blob = await downloadRequirements(name); const blob = await downloadRequirements(name);
const text = await blob.text(); const text = await blob.text();
@@ -201,30 +225,28 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
setReqEditingName(name); setReqEditingName(name);
setReqModalVisible(true); setReqModalVisible(true);
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Failed to open requirements', description: err?.message || String(err) }); notification.error({ message: 'Failed to open requirements', description: err?.message ?? String(err) });
}
} }
}, []);
// Save edited requirements (uploads and runs pip install) const handleSaveRequirements = useCallback(
async function handleSaveRequirements(name: string, content: string) { async (name: string, content: string) => {
try { try {
// create blob and upload via API -> returns pip output
const resp = await uploadRequirements(name, new Blob([content], { type: 'text/plain' })); const resp = await uploadRequirements(name, new Blob([content], { type: 'text/plain' }));
if (resp?.pip) { showPipIfPresent(resp);
setPipOutput(resp.pip);
setPipModalVisible(true);
}
setReqModalVisible(false); setReqModalVisible(false);
setReqEditingName(null); setReqEditingName(null);
setReqEditorValue(''); setReqEditorValue('');
// refresh
await refreshAll(); await refreshAll();
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Failed to upload requirements', description: err?.message || String(err) }); notification.error({ message: 'Failed to upload requirements', description: err?.message ?? String(err) });
}
} }
},
[refreshAll, showPipIfPresent],
);
async function handleDeleteRequirements(name: string) { const handleDeleteRequirements = useCallback(
(name: string) => {
Modal.confirm({ Modal.confirm({
title: `Delete requirements for ${name}?`, title: `Delete requirements for ${name}?`,
content: `This will remove requirements.txt and attempt to clean up the venv for ${name}.`, content: `This will remove requirements.txt and attempt to clean up the venv for ${name}.`,
@@ -235,21 +257,24 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
message: 'requirements deleted', message: 'requirements deleted',
description: `requirements removed: ${res.removed?.requirements_removed ? 'yes' : 'no'}`, description: `requirements removed: ${res.removed?.requirements_removed ? 'yes' : 'no'}`,
}); });
if (res.errors && res.errors.length) { if ((res as any).errors && (res as any).errors.length) {
Modal.info({ Modal.info({
title: 'Delete requirements returned errors', title: 'Delete requirements returned errors',
content: <pre style={{ maxHeight: 240, overflow: 'auto' }}>{res.errors.join('\n')}</pre>, content: <pre style={{ maxHeight: 240, overflow: 'auto' }}>{(res as any).errors.join('\n')}</pre>,
}); });
} }
await refreshAll(); await refreshAll();
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Failed to delete requirements', description: err?.message || String(err) }); notification.error({ message: 'Failed to delete requirements', description: err?.message ?? String(err) });
} }
}, },
}); });
} },
[refreshAll],
);
async function handleDelete(name: string) { const handleDelete = useCallback(
async (name: string) => {
try { try {
const res: DeleteResult = await deleteScript(name); const res: DeleteResult = await deleteScript(name);
notification.success({ message: 'Deleted', description: `${name} removed` }); notification.success({ message: 'Deleted', description: `${name} removed` });
@@ -266,11 +291,14 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
} }
await refreshAll(); await refreshAll();
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Delete failed', description: err?.message || String(err) }); notification.error({ message: 'Delete failed', description: err?.message ?? String(err) });
}
} }
},
[refreshAll],
);
async function handleDisableInstance(scriptName: string, mapping: UnitMapping) { const handleDisableInstance = useCallback(
(scriptName: string, mapping: any) => {
const qnum = mapping.parsed.qnum; const qnum = mapping.parsed.qnum;
if (typeof qnum !== 'number') { if (typeof qnum !== 'number') {
notification.warning({ message: 'Cannot disable instance', description: 'qnum not available for this unit' }); notification.warning({ message: 'Cannot disable instance', description: 'qnum not available for this unit' });
@@ -281,33 +309,41 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
content: `This will attempt to stop and remove unit ${mapping.service} (qnum ${qnum}).`, content: `This will attempt to stop and remove unit ${mapping.service} (qnum ${qnum}).`,
onOk: async () => { onOk: async () => {
try { try {
const res: OperationResult = await disableScript(scriptName, qnum); await disableScript(scriptName, qnum);
notification.success({ message: 'Disabled', description: `${mapping.service} disabled` }); notification.success({ message: 'Disabled', description: `${mapping.service} disabled` });
await refreshAll(); await refreshAll();
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Disable failed', description: err?.message || String(err) }); notification.error({ message: 'Disable failed', description: err?.message ?? String(err) });
} }
}, },
}); });
} },
[refreshAll],
);
function fileToBlob(file: File | Blob) { // --------------------
return file; // Upload handler (prefers file; falls back to inline editors)
} // --------------------
const handleUpload = useCallback(
async function handleUpload(formValues: any) { async (formValues: any) => {
const { name, scriptFile, requirementsFile } = formValues; const { name, scriptFile, requirementsFile } = formValues;
if (!name || (!scriptFile && !editorValue && !currentEditingName)) { if (!name || (!scriptFile && !editorValue && !currentEditingName)) {
notification.warning({ message: 'Please provide a name and script' }); notification.warning({ message: 'Please provide a name and script' });
return; return;
} }
const script = // script: prefer uploaded file; else use editorValue
scriptFile?.file || const script = scriptFile || new Blob([editorValue], { type: 'text/x-python' });
(currentEditingName
? new Blob([editorValue], { type: 'text/x-python' }) // requirements: prefer uploaded file; else use inlineReqValue if toggle enabled; else null
: new Blob([editorValue], { type: 'text/x-python' })); let req: File | Blob | null = null;
const req = requirementsFile?.file || null; if (requirementsFile) {
req = requirementsFile;
} else if (useInlineReqEditor && inlineReqValue && inlineReqValue.trim() !== '') {
req = new Blob([inlineReqValue], { type: 'text/plain' });
} else {
req = null;
}
try { try {
const resp: ScriptUploadResponse = await uploadScript({ const resp: ScriptUploadResponse = await uploadScript({
@@ -317,59 +353,57 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
}); });
notification.success({ message: 'Uploaded', description: `${name} uploaded` }); notification.success({ message: 'Uploaded', description: `${name} uploaded` });
showPipIfPresent(resp);
if (resp.pip && (resp.pip.stdout || resp.pip.stderr)) {
setPipOutput(resp.pip);
setPipModalVisible(true);
}
setUploadModalVisible(false); setUploadModalVisible(false);
form.resetFields(); form.resetFields();
setCurrentEditingName(null); setCurrentEditingName(null);
setUseInlineReqEditor(false);
setInlineReqValue('');
await refreshAll(); await refreshAll();
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Upload failed', description: err?.message || String(err) }); notification.error({ message: 'Upload failed', description: err?.message ?? String(err) });
}
} }
},
[editorValue, currentEditingName, form, inlineReqValue, refreshAll, showPipIfPresent, useInlineReqEditor],
);
async function openInEditorFromServer(name: string) { const openInEditorFromServer = useCallback(
async (name: string) => {
try { try {
const blob = await downloadScript(name); // Blob const blob = await downloadScript(name);
const text = await blob.text(); const text = await blob.text();
setEditorValue(text); setEditorValue(text);
setEditorModalVisible(true); setEditorModalVisible(true);
setCurrentEditingName(name); setCurrentEditingName(name);
if (onOpenInEditor) onOpenInEditor(text); if (onOpenInEditor) onOpenInEditor(text);
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Failed to open', description: err?.message || String(err) }); notification.error({ message: 'Failed to open', description: err?.message ?? String(err) });
}
} }
},
[onOpenInEditor],
);
async function handleSaveFromEditorAs(name: string) { const handleSaveFromEditorAs = useCallback(
async (name: string) => {
try { try {
const resp: ScriptUploadResponse = await uploadScript({ const resp: ScriptUploadResponse = await uploadScript({
name, name,
script: new Blob([editorValue], { type: 'text/x-python' }), script: new Blob([editorValue], { type: 'text/x-python' }),
}); });
notification.success({ message: 'Saved', description: `${name} uploaded` }); notification.success({ message: 'Saved', description: `${name} uploaded` });
showPipIfPresent(resp);
if (resp.pip && (resp.pip.stdout || resp.pip.stderr)) {
setPipOutput(resp.pip);
setPipModalVisible(true);
}
setEditorModalVisible(false); setEditorModalVisible(false);
await refreshAll(); await refreshAll();
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Save failed', description: err?.message || String(err) }); notification.error({ message: 'Save failed', description: err?.message ?? String(err) });
}
} }
},
[editorValue, refreshAll, showPipIfPresent],
);
// enable modal/form const handleEnableSubmit = useCallback(
const [enableModalVisible, setEnableModalVisible] = useState(false); async (values: any) => {
const [enableTarget, setEnableTarget] = useState<string | null>(null);
async function handleEnableSubmit(values: any) {
if (!enableTarget) return; if (!enableTarget) return;
const req: EnableRequest = { const req: EnableRequest = {
qnum: Number(values.qnum), qnum: Number(values.qnum),
@@ -385,24 +419,64 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
enableForm.resetFields(); enableForm.resetFields();
await refreshAll(); await refreshAll();
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Enable failed', description: err?.message || String(err) }); notification.error({ message: 'Enable failed', description: err?.message ?? String(err) });
}
} }
},
[enableTarget, enableForm, refreshAll],
);
async function handleDisable(name: string) { const handleDisable = useCallback(
async (name: string) => {
try { try {
// default qnum fallback
const qnum = 0; const qnum = 0;
const res: OperationResult = await disableScript(name, qnum); const res: OperationResult = await disableScript(name, qnum);
notification.success({ message: 'Disabled', description: `${name} disabled (service ${res.service})` }); notification.success({ message: 'Disabled', description: `${name} disabled (service ${res.service})` });
await refreshAll(); await refreshAll();
} catch (err: any) { } catch (err: any) {
notification.error({ message: 'Disable failed', description: err?.message || String(err) }); notification.error({ message: 'Disable failed', description: err?.message ?? String(err) });
} }
},
[refreshAll],
);
// --------------------
// Upload form helpers
// --------------------
// Normalize Upload event to return single File object for Form storage
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;
};
// Icon-only button wrapped in Tooltip
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>
);
} }
// Table columns for main script list // --------------------
const columns: ColumnsType<ScriptInfo> = [ // Table columns
// --------------------
const columns: ColumnsType<ScriptWithStatus> = useMemo(
() => [
{ {
title: 'Name', title: 'Name',
dataIndex: 'name', dataIndex: 'name',
@@ -421,15 +495,22 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
render: (_: any, record: ScriptWithStatus) => render: (_: any, record: ScriptWithStatus) =>
record.requirements_exists ? ( record.requirements_exists ? (
<Space> <Space>
<Button size="small" onClick={() => handleDownloadRequirements(record.name)} icon={<DownloadOutlined />}> <IconButtonTooltip
Download title="Download requirements"
</Button> onClick={() => handleDownloadRequirements(record.name)}
<Button size="small" onClick={() => handleEditRequirements(record.name)} icon={<EditOutlined />}> icon={<DownloadOutlined />}
Edit />
</Button> <IconButtonTooltip
<Button size="small" danger onClick={() => handleDeleteRequirements(record.name)}> title="Edit requirements"
Delete onClick={() => handleEditRequirements(record.name)}
</Button> icon={<EditOutlined />}
/>
<IconButtonTooltip
title="Delete requirements"
danger
onClick={() => handleDeleteRequirements(record.name)}
icon={<DeleteOutlined />}
/>
</Space> </Space>
) : ( ) : (
<span style={{ color: '#999' }}>—</span> <span style={{ color: '#999' }}>—</span>
@@ -438,44 +519,54 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
{ {
title: 'Actions', title: 'Actions',
key: 'actions', key: 'actions',
width: 320, width: 240,
render: (_, record: ScriptWithStatus) => ( render: (_: any, record: ScriptWithStatus) => (
<Space> <Space>
<Tooltip title="Download"> <IconButtonTooltip
<Button icon={<DownloadOutlined />} onClick={() => handleDownload(record.name)} /> title="Download script"
</Tooltip> onClick={() => handleDownload(record.name)}
<Tooltip title="Open in editor"> icon={<DownloadOutlined />}
<Button icon={<EditOutlined />} onClick={() => openInEditorFromServer(record.name)} /> />
</Tooltip> <IconButtonTooltip
<Tooltip title="Enable"> title="Open in editor"
<Button onClick={() => openInEditorFromServer(record.name)}
icon={<PlayCircleOutlined />} icon={<EditOutlined />}
/>
<IconButtonTooltip
title="Enable"
onClick={() => { onClick={() => {
setEnableTarget(record.name); setEnableTarget(record.name);
setEnableModalVisible(true); setEnableModalVisible(true);
}} }}
icon={<PlayCircleOutlined />}
/> />
</Tooltip>
<Tooltip title="Disable (defaults to qnum=0)">
<Button icon={<PauseCircleOutlined />} onClick={() => handleDisable(record.name)} />
</Tooltip>
<Popconfirm title={`Delete ${record.name}?`} onConfirm={() => handleDelete(record.name)}> <Popconfirm title={`Delete ${record.name}?`} onConfirm={() => handleDelete(record.name)}>
<Button danger icon={<DeleteOutlined />} /> <Tooltip title="Delete script">
<Button danger size="small" icon={<DeleteOutlined />} />
</Tooltip>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
}, },
]; ],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
// nested columns for unit mappings when a script row is expanded const nestedColumns = useMemo(
const nestedColumns = [ () => [
{ {
title: 'Service', title: 'Service',
dataIndex: ['parsed', 'service'], dataIndex: ['parsed', 'service'],
key: 'service', key: 'service',
render: (_: any, r: UnitMapping) => <code>{r.service}</code>, render: (_: any, r: any) => <code>{r.service}</code>,
},
{
title: 'qnum',
dataIndex: ['parsed', 'qnum'],
key: 'qnum',
render: (q: number | null) => (q == null ? '-' : q),
}, },
{ title: 'qnum', dataIndex: ['parsed', 'qnum'], key: 'qnum', render: (q: number | null) => (q == null ? '-' : q) },
{ title: 'Active', dataIndex: 'active', key: 'active', render: (a: boolean) => (a ? 'yes' : 'no') }, { title: 'Active', dataIndex: 'active', key: 'active', render: (a: boolean) => (a ? 'yes' : 'no') },
{ title: 'Extra', dataIndex: ['parsed', 'extra'], key: 'extra', render: (e: string | null) => e || '-' }, { title: 'Extra', dataIndex: ['parsed', 'extra'], key: 'extra', render: (e: string | null) => e || '-' },
{ {
@@ -487,23 +578,24 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
{ {
title: 'Actions', title: 'Actions',
key: 'unit_actions', key: 'unit_actions',
render: (_: any, r: UnitMapping) => ( render: (_: any, r: any) => (
<Space> <Space>
<Button <IconButtonTooltip
onClick={() => handleDisableInstance(r.parsed.name ?? r.parsed.service ?? '', r)} title="Disable instance"
danger danger
onClick={() => handleDisableInstance(r.parsed.name ?? r.parsed.service ?? '', r)}
icon={<PauseCircleOutlined />} icon={<PauseCircleOutlined />}
disabled={typeof r.parsed.qnum !== 'number'} disabled={typeof r.parsed.qnum !== 'number'}
> />
Disable instance
</Button>
</Space> </Space>
), ),
}, },
]; ],
[handleDisableInstance],
);
// Table expandable config const expandable = useMemo(
const expandable = { () => ({
expandedRowRender: (record: ScriptWithStatus) => { expandedRowRender: (record: ScriptWithStatus) => {
const mappings = getMappingsForScript(record.name); const mappings = getMappingsForScript(record.name);
if (!mappings || mappings.length === 0) { if (!mappings || mappings.length === 0) {
@@ -514,7 +606,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
columns={nestedColumns} columns={nestedColumns}
dataSource={mappings} dataSource={mappings}
pagination={false} pagination={false}
rowKey={(r: UnitMapping) => r.service} rowKey={(r: any) => r.service}
size="small" size="small"
/> />
); );
@@ -523,10 +615,15 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
const mappings = getMappingsForScript(record.name); const mappings = getMappingsForScript(record.name);
return mappings.length > 0; return mappings.length > 0;
}, },
defaultExpandAllRows: true, // everything expanded by default defaultExpandAllRows: true,
expandRowByClick: false, expandRowByClick: false,
}; }),
[getMappingsForScript, nestedColumns],
);
// --------------------
// Render
// --------------------
return ( return (
<div> <div>
<Row justify="space-between" style={{ marginBottom: 12 }}> <Row justify="space-between" style={{ marginBottom: 12 }}>
@@ -535,8 +632,9 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
<Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadModalVisible(true)}> <Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadModalVisible(true)}>
Upload / New Upload / New
</Button> </Button>
<Button onClick={refreshAll}>Refresh</Button> <Button onClick={refreshAll} icon={loading ? <Spin size="small" /> : <ReloadOutlined />}>
<Button onClick={refreshStatuses}>{fetchingStatuses ? <Spin size="small" /> : 'Statuses'}</Button> Refresh
</Button>
</Space> </Space>
</Col> </Col>
</Row> </Row>
@@ -550,39 +648,60 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
expandable={expandable} expandable={expandable}
/> />
{/* Upload Modal */} {/* Upload Modal (wider) */}
<Modal <Modal
open={uploadModalVisible} open={uploadModalVisible}
title="Upload or create script" title="Upload or create script"
onCancel={() => { onCancel={() => {
setUploadModalVisible(false); setUploadModalVisible(false);
form.resetFields(); form.resetFields();
setUseInlineReqEditor(false);
setInlineReqValue('');
}} }}
footer={null} footer={null}
width={900}
> >
<Form form={form} layout="vertical" onFinish={handleUpload}> <Form form={form} layout="vertical" onFinish={handleUpload}>
<Form.Item name="name" label="Script name" rules={[{ required: true }]}> <Form.Item name="name" label="Script name" rules={[{ required: true }]}>
<Input placeholder="my_script" /> <Input placeholder="my_script" />
</Form.Item> </Form.Item>
<Form.Item label="Script file"> <Form.Item label="Script file (optional: prefer file if provided)">
<Form.Item name="scriptFile" noStyle> <Form.Item name="scriptFile" valuePropName="file" getValueFromEvent={normFile} noStyle>
<Upload beforeUpload={() => false} maxCount={1}> <Upload beforeUpload={() => false} maxCount={1} accept=".py">
<Button icon={<UploadOutlined />}>Choose script file</Button> <Button icon={<UploadOutlined />}>Choose script file</Button>
</Upload> </Upload>
</Form.Item> </Form.Item>
<div style={{ marginTop: 8, color: '#666' }}>Or paste your script in the editor below.</div>
</Form.Item> </Form.Item>
<Form.Item label="requirements.txt (optional)"> <Form.Item label="requirements.txt (optional)">
<Form.Item name="requirementsFile" noStyle> <Space direction="vertical" style={{ width: '100%' }}>
<Upload beforeUpload={() => false} maxCount={1}> <Form.Item name="requirementsFile" valuePropName="file" getValueFromEvent={normFile} noStyle>
<Button icon={<UploadOutlined />}>Choose requirements</Button> <Upload beforeUpload={() => false} maxCount={1} accept=".txt">
<Button icon={<UploadOutlined />}>Choose requirements file</Button>
</Upload> </Upload>
</Form.Item> </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>
<Form.Item label="Or paste/edit script (editor)"> <Form.Item label="Or paste/edit script (editor)">
<PythonEditor value={editorValue} onChange={setEditorValue} height={220} /> <PythonEditor value={editorValue} onChange={setEditorValue} height={300} />
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
@@ -591,6 +710,8 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
onClick={() => { onClick={() => {
setUploadModalVisible(false); setUploadModalVisible(false);
form.resetFields(); form.resetFields();
setUseInlineReqEditor(false);
setInlineReqValue('');
}} }}
> >
Cancel Cancel
@@ -603,7 +724,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
</Form> </Form>
</Modal> </Modal>
{/* Editor Modal (open existing script) */} {/* Editor Modal */}
<Modal <Modal
open={editorModalVisible} open={editorModalVisible}
title={currentEditingName ? `Editing — ${currentEditingName}` : 'Editor'} title={currentEditingName ? `Editing — ${currentEditingName}` : 'Editor'}
@@ -614,7 +735,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
<Button onClick={() => setEditorModalVisible(false)}>Close</Button> <Button onClick={() => setEditorModalVisible(false)}>Close</Button>
<Button <Button
type="default" type="default"
onClick={() => { onClick={() =>
Modal.confirm({ Modal.confirm({
title: 'Save as', title: 'Save as',
content: ( content: (
@@ -638,8 +759,8 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
icon: null, icon: null,
okButtonProps: { style: { display: 'none' } }, okButtonProps: { style: { display: 'none' } },
cancelButtonProps: { style: { display: 'none' } }, cancelButtonProps: { style: { display: 'none' } },
}); })
}} }
> >
Save as... Save as...
</Button> </Button>
@@ -661,49 +782,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
<PythonEditor value={editorValue} onChange={setEditorValue} height={520} /> <PythonEditor value={editorValue} onChange={setEditorValue} height={520} />
</Modal> </Modal>
{/* Enable Modal */} {/* Requirements Editor Modal (per-script) */}
<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>
{/* Requirements Editor Modal */}
<Modal <Modal
open={reqModalVisible} open={reqModalVisible}
title={reqEditingName ? `requirements.txt — ${reqEditingName}` : 'requirements.txt'} title={reqEditingName ? `requirements.txt — ${reqEditingName}` : 'requirements.txt'}
@@ -760,6 +839,48 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
<pre style={{ whiteSpace: 'pre-wrap', color: 'red' }}>{pipOutput?.stderr ?? '(none)'}</pre> <pre style={{ whiteSpace: 'pre-wrap', color: 'red' }}>{pipOutput?.stderr ?? '(none)'}</pre>
</div> </div>
</Modal> </Modal>
{/* Enable 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> </div>
); );
} }