api fix and script fe improv
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s
This commit is contained in:
@@ -494,10 +494,6 @@ async def upload_script(
|
||||
logger.warning("Invalid name provided: %s", name)
|
||||
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)
|
||||
if os.path.exists(spath):
|
||||
logger.warning("Upload rejected: script with name already exists: %s", name)
|
||||
|
||||
@@ -1,76 +1,111 @@
|
||||
// src/components/PythonEditor.tsx
|
||||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/themes/prism.css';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import Editor from 'react-simple-code-editor';
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
// Props for the editor
|
||||
export type PythonEditorProps = {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
height?: string | number; // e.g. '200px' or 200
|
||||
wrap?: boolean; // if true, long lines will wrap; default false (no wrap)
|
||||
height?: string | number;
|
||||
wrap?: boolean;
|
||||
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({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
height = 480,
|
||||
className = '',
|
||||
wrap = false,
|
||||
fontSize = 14,
|
||||
tabSize = 4,
|
||||
}: PythonEditorProps) {
|
||||
const highlight = useCallback((code: string) => {
|
||||
try {
|
||||
return Prism.highlight(code, Prism.languages.python, 'python');
|
||||
} catch (e) {
|
||||
return code;
|
||||
}
|
||||
}, []);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const preRef = useRef<HTMLPreElement | null>(null);
|
||||
|
||||
// container style: fixed height + scrolling
|
||||
const containerStyle = useMemo(() => {
|
||||
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 editorStyle = useMemo(
|
||||
const lineHeight = Math.round(fontSize * 1.6);
|
||||
|
||||
// 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',
|
||||
fontSize: 14,
|
||||
// make editor fill the container (pre/textarea inside will take up height)
|
||||
minHeight: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
fontFamily: DEFAULT_FONT,
|
||||
fontSize: `${fontSize}px`,
|
||||
lineHeight: `${lineHeight}px`,
|
||||
tabSize,
|
||||
whiteSpace: wrap ? 'pre-wrap' : 'pre',
|
||||
wordBreak: 'break-word',
|
||||
padding: 12,
|
||||
outline: 'none',
|
||||
whiteSpace: wrap ? 'pre-wrap' : 'pre', // pre-wrap if wrap requested
|
||||
wordBreak: 'break-word' as const,
|
||||
boxSizing: 'border-box',
|
||||
}),
|
||||
[wrap],
|
||||
[fontSize, lineHeight, tabSize, wrap],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`python-editor ${className}`} style={containerStyle}>
|
||||
<Editor
|
||||
<div
|
||||
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}
|
||||
onValueChange={(code) => onChange(code)}
|
||||
highlight={highlight}
|
||||
padding={0} // padding handled by editorStyle
|
||||
preClassName="language-python"
|
||||
textareaId="python-editor"
|
||||
style={editorStyle}
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// src/pages/ScriptsManager.tsx
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
@@ -5,10 +6,12 @@ import {
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
@@ -24,7 +27,8 @@ import {
|
||||
Upload,
|
||||
} from 'antd';
|
||||
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 {
|
||||
deleteRequirements,
|
||||
@@ -34,7 +38,6 @@ import {
|
||||
downloadScript,
|
||||
enableScript,
|
||||
fetchScriptsAll,
|
||||
listScripts,
|
||||
uploadRequirements,
|
||||
uploadScript,
|
||||
} from '../api/apiClient';
|
||||
@@ -43,104 +46,80 @@ import {
|
||||
DeleteResult,
|
||||
EnableRequest,
|
||||
OperationResult,
|
||||
ScriptInfo,
|
||||
ScriptUploadResponse,
|
||||
ScriptWithStatus,
|
||||
} from '../types/scripting';
|
||||
|
||||
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 = {
|
||||
service: string;
|
||||
exec_start?: string | null;
|
||||
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)
|
||||
export default function ScriptsManager({ onOpenInEditor }: { onOpenInEditor?: (code: string) => void }) {
|
||||
// --------------------
|
||||
// State
|
||||
// --------------------
|
||||
const [scripts, setScripts] = useState<ScriptWithStatus[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// modals & editor values
|
||||
const [uploadModalVisible, setUploadModalVisible] = useState(false);
|
||||
const [editorModalVisible, setEditorModalVisible] = useState(false);
|
||||
const [editorValue, setEditorValue] = useState<string>('');
|
||||
const [currentEditingName, setCurrentEditingName] = useState<string | null>(null);
|
||||
const [fetchingStatuses, setFetchingStatuses] = useState(false);
|
||||
|
||||
// inline requirements editor toggle + value for upload modal
|
||||
const [useInlineReqEditor, setUseInlineReqEditor] = useState(false);
|
||||
const [inlineReqValue, setInlineReqValue] = useState<string>('');
|
||||
|
||||
// pip output modal
|
||||
const [pipModalVisible, setPipModalVisible] = useState(false);
|
||||
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 [reqEditorValue, setReqEditorValue] = useState<string>('');
|
||||
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 [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() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
refreshAll();
|
||||
}, [refreshAll]);
|
||||
|
||||
async function refreshList() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await listScripts();
|
||||
// map listScripts (basic) onto current scripts while preserving existing mappings where possible
|
||||
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[] => {
|
||||
// --------------------
|
||||
// Helpers
|
||||
// --------------------
|
||||
const getMappingsForScript = useCallback(
|
||||
(scriptName: string) => {
|
||||
const s = scripts.find((x) => x.name === scriptName);
|
||||
if (!s || !s.mappings) return [];
|
||||
return s.mappings.map((m) => ({
|
||||
@@ -155,11 +134,58 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
},
|
||||
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 {
|
||||
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 a = document.createElement('a');
|
||||
a.href = url;
|
||||
@@ -170,12 +196,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
URL.revokeObjectURL(url);
|
||||
notification.success({ message: 'Download started' });
|
||||
} 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
|
||||
async function handleDownloadRequirements(name: string) {
|
||||
const handleDownloadRequirements = useCallback(async (name: string) => {
|
||||
try {
|
||||
const blob = await downloadRequirements(name);
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -188,12 +213,11 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
URL.revokeObjectURL(url);
|
||||
notification.success({ message: 'requirements.txt download started' });
|
||||
} 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)
|
||||
async function handleEditRequirements(name: string) {
|
||||
const handleEditRequirements = useCallback(async (name: string) => {
|
||||
try {
|
||||
const blob = await downloadRequirements(name);
|
||||
const text = await blob.text();
|
||||
@@ -201,30 +225,28 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
setReqEditingName(name);
|
||||
setReqModalVisible(true);
|
||||
} 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)
|
||||
async function handleSaveRequirements(name: string, content: string) {
|
||||
const handleSaveRequirements = useCallback(
|
||||
async (name: string, content: string) => {
|
||||
try {
|
||||
// create blob and upload via API -> returns pip output
|
||||
const resp = await uploadRequirements(name, new Blob([content], { type: 'text/plain' }));
|
||||
if (resp?.pip) {
|
||||
setPipOutput(resp.pip);
|
||||
setPipModalVisible(true);
|
||||
}
|
||||
showPipIfPresent(resp);
|
||||
setReqModalVisible(false);
|
||||
setReqEditingName(null);
|
||||
setReqEditorValue('');
|
||||
// refresh
|
||||
await refreshAll();
|
||||
} 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({
|
||||
title: `Delete requirements 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',
|
||||
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({
|
||||
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();
|
||||
} 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 {
|
||||
const res: DeleteResult = await deleteScript(name);
|
||||
notification.success({ message: 'Deleted', description: `${name} removed` });
|
||||
@@ -266,11 +291,14 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
}
|
||||
await refreshAll();
|
||||
} 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;
|
||||
if (typeof qnum !== 'number') {
|
||||
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}).`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
const res: OperationResult = await disableScript(scriptName, qnum);
|
||||
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) });
|
||||
notification.error({ message: 'Disable failed', description: err?.message ?? String(err) });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
[refreshAll],
|
||||
);
|
||||
|
||||
function fileToBlob(file: File | Blob) {
|
||||
return file;
|
||||
}
|
||||
|
||||
async function handleUpload(formValues: any) {
|
||||
// --------------------
|
||||
// Upload handler (prefers file; falls back to inline editors)
|
||||
// --------------------
|
||||
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?.file ||
|
||||
(currentEditingName
|
||||
? new Blob([editorValue], { type: 'text/x-python' })
|
||||
: new Blob([editorValue], { type: 'text/x-python' }));
|
||||
const req = requirementsFile?.file || null;
|
||||
// script: prefer uploaded file; else use editorValue
|
||||
const script = scriptFile || new Blob([editorValue], { type: 'text/x-python' });
|
||||
|
||||
// requirements: prefer uploaded file; else use inlineReqValue if toggle enabled; else null
|
||||
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({
|
||||
@@ -317,59 +353,57 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
});
|
||||
|
||||
notification.success({ message: 'Uploaded', description: `${name} uploaded` });
|
||||
|
||||
if (resp.pip && (resp.pip.stdout || resp.pip.stderr)) {
|
||||
setPipOutput(resp.pip);
|
||||
setPipModalVisible(true);
|
||||
}
|
||||
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) });
|
||||
}
|
||||
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 {
|
||||
const blob = await downloadScript(name); // Blob
|
||||
const blob = await downloadScript(name);
|
||||
const text = await blob.text();
|
||||
setEditorValue(text);
|
||||
setEditorModalVisible(true);
|
||||
setCurrentEditingName(name);
|
||||
if (onOpenInEditor) onOpenInEditor(text);
|
||||
} 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 {
|
||||
const resp: ScriptUploadResponse = await uploadScript({
|
||||
name,
|
||||
script: new Blob([editorValue], { type: 'text/x-python' }),
|
||||
});
|
||||
notification.success({ message: 'Saved', description: `${name} uploaded` });
|
||||
|
||||
if (resp.pip && (resp.pip.stdout || resp.pip.stderr)) {
|
||||
setPipOutput(resp.pip);
|
||||
setPipModalVisible(true);
|
||||
}
|
||||
|
||||
showPipIfPresent(resp);
|
||||
setEditorModalVisible(false);
|
||||
await refreshAll();
|
||||
} 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 [enableModalVisible, setEnableModalVisible] = useState(false);
|
||||
const [enableTarget, setEnableTarget] = useState<string | null>(null);
|
||||
|
||||
async function handleEnableSubmit(values: any) {
|
||||
const handleEnableSubmit = useCallback(
|
||||
async (values: any) => {
|
||||
if (!enableTarget) return;
|
||||
const req: EnableRequest = {
|
||||
qnum: Number(values.qnum),
|
||||
@@ -385,24 +419,64 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
enableForm.resetFields();
|
||||
await refreshAll();
|
||||
} 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 {
|
||||
// default qnum fallback
|
||||
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) });
|
||||
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',
|
||||
dataIndex: 'name',
|
||||
@@ -421,15 +495,22 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
render: (_: any, record: ScriptWithStatus) =>
|
||||
record.requirements_exists ? (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => handleDownloadRequirements(record.name)} icon={<DownloadOutlined />}>
|
||||
Download
|
||||
</Button>
|
||||
<Button size="small" onClick={() => handleEditRequirements(record.name)} icon={<EditOutlined />}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button size="small" danger onClick={() => handleDeleteRequirements(record.name)}>
|
||||
Delete
|
||||
</Button>
|
||||
<IconButtonTooltip
|
||||
title="Download requirements"
|
||||
onClick={() => handleDownloadRequirements(record.name)}
|
||||
icon={<DownloadOutlined />}
|
||||
/>
|
||||
<IconButtonTooltip
|
||||
title="Edit requirements"
|
||||
onClick={() => handleEditRequirements(record.name)}
|
||||
icon={<EditOutlined />}
|
||||
/>
|
||||
<IconButtonTooltip
|
||||
title="Delete requirements"
|
||||
danger
|
||||
onClick={() => handleDeleteRequirements(record.name)}
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
</Space>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>—</span>
|
||||
@@ -438,44 +519,54 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 320,
|
||||
render: (_, record: ScriptWithStatus) => (
|
||||
width: 240,
|
||||
render: (_: any, record: ScriptWithStatus) => (
|
||||
<Space>
|
||||
<Tooltip title="Download">
|
||||
<Button icon={<DownloadOutlined />} onClick={() => handleDownload(record.name)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Open in editor">
|
||||
<Button icon={<EditOutlined />} onClick={() => openInEditorFromServer(record.name)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Enable">
|
||||
<Button
|
||||
icon={<PlayCircleOutlined />}
|
||||
<IconButtonTooltip
|
||||
title="Download script"
|
||||
onClick={() => handleDownload(record.name)}
|
||||
icon={<DownloadOutlined />}
|
||||
/>
|
||||
<IconButtonTooltip
|
||||
title="Open in editor"
|
||||
onClick={() => openInEditorFromServer(record.name)}
|
||||
icon={<EditOutlined />}
|
||||
/>
|
||||
<IconButtonTooltip
|
||||
title="Enable"
|
||||
onClick={() => {
|
||||
setEnableTarget(record.name);
|
||||
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)}>
|
||||
<Button danger icon={<DeleteOutlined />} />
|
||||
<Tooltip title="Delete script">
|
||||
<Button danger size="small" icon={<DeleteOutlined />} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[],
|
||||
);
|
||||
|
||||
// nested columns for unit mappings when a script row is expanded
|
||||
const nestedColumns = [
|
||||
const nestedColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: 'Service',
|
||||
dataIndex: ['parsed', '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: 'Extra', dataIndex: ['parsed', 'extra'], key: 'extra', render: (e: string | null) => e || '-' },
|
||||
{
|
||||
@@ -487,23 +578,24 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'unit_actions',
|
||||
render: (_: any, r: UnitMapping) => (
|
||||
render: (_: any, r: any) => (
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => handleDisableInstance(r.parsed.name ?? r.parsed.service ?? '', r)}
|
||||
<IconButtonTooltip
|
||||
title="Disable instance"
|
||||
danger
|
||||
onClick={() => handleDisableInstance(r.parsed.name ?? r.parsed.service ?? '', r)}
|
||||
icon={<PauseCircleOutlined />}
|
||||
disabled={typeof r.parsed.qnum !== 'number'}
|
||||
>
|
||||
Disable instance
|
||||
</Button>
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
],
|
||||
[handleDisableInstance],
|
||||
);
|
||||
|
||||
// Table expandable config
|
||||
const expandable = {
|
||||
const expandable = useMemo(
|
||||
() => ({
|
||||
expandedRowRender: (record: ScriptWithStatus) => {
|
||||
const mappings = getMappingsForScript(record.name);
|
||||
if (!mappings || mappings.length === 0) {
|
||||
@@ -514,7 +606,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
columns={nestedColumns}
|
||||
dataSource={mappings}
|
||||
pagination={false}
|
||||
rowKey={(r: UnitMapping) => r.service}
|
||||
rowKey={(r: any) => r.service}
|
||||
size="small"
|
||||
/>
|
||||
);
|
||||
@@ -523,10 +615,15 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
const mappings = getMappingsForScript(record.name);
|
||||
return mappings.length > 0;
|
||||
},
|
||||
defaultExpandAllRows: true, // everything expanded by default
|
||||
defaultExpandAllRows: true,
|
||||
expandRowByClick: false,
|
||||
};
|
||||
}),
|
||||
[getMappingsForScript, nestedColumns],
|
||||
);
|
||||
|
||||
// --------------------
|
||||
// Render
|
||||
// --------------------
|
||||
return (
|
||||
<div>
|
||||
<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)}>
|
||||
Upload / New
|
||||
</Button>
|
||||
<Button onClick={refreshAll}>Refresh</Button>
|
||||
<Button onClick={refreshStatuses}>{fetchingStatuses ? <Spin size="small" /> : 'Statuses'}</Button>
|
||||
<Button onClick={refreshAll} icon={loading ? <Spin size="small" /> : <ReloadOutlined />}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -550,39 +648,60 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
expandable={expandable}
|
||||
/>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{/* Upload Modal (wider) */}
|
||||
<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">
|
||||
<Form.Item name="scriptFile" noStyle>
|
||||
<Upload beforeUpload={() => false} maxCount={1}>
|
||||
<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)">
|
||||
<Form.Item name="requirementsFile" noStyle>
|
||||
<Upload beforeUpload={() => false} maxCount={1}>
|
||||
<Button icon={<UploadOutlined />}>Choose requirements</Button>
|
||||
<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={220} />
|
||||
<PythonEditor value={editorValue} onChange={setEditorValue} height={300} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
@@ -591,6 +710,8 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
onClick={() => {
|
||||
setUploadModalVisible(false);
|
||||
form.resetFields();
|
||||
setUseInlineReqEditor(false);
|
||||
setInlineReqValue('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
@@ -603,7 +724,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Editor Modal (open existing script) */}
|
||||
{/* Editor Modal */}
|
||||
<Modal
|
||||
open={editorModalVisible}
|
||||
title={currentEditingName ? `Editing — ${currentEditingName}` : 'Editor'}
|
||||
@@ -614,7 +735,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
<Button onClick={() => setEditorModalVisible(false)}>Close</Button>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: 'Save as',
|
||||
content: (
|
||||
@@ -638,8 +759,8 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
icon: null,
|
||||
okButtonProps: { style: { display: 'none' } },
|
||||
cancelButtonProps: { style: { display: 'none' } },
|
||||
});
|
||||
}}
|
||||
})
|
||||
}
|
||||
>
|
||||
Save as...
|
||||
</Button>
|
||||
@@ -661,49 +782,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
|
||||
<PythonEditor value={editorValue} onChange={setEditorValue} height={520} />
|
||||
</Modal>
|
||||
|
||||
{/* Enable Modal */}
|
||||
<Modal
|
||||
open={enableModalVisible}
|
||||
title={`Enable ${enableTarget ?? ''}`}
|
||||
onCancel={() => {
|
||||
setEnableModalVisible(false);
|
||||
setEnableTarget(null);
|
||||
}}
|
||||
footer={null}
|
||||
>
|
||||
<Form form={enableForm} layout="vertical" onFinish={handleEnableSubmit}>
|
||||
<Form.Item name="qnum" label="Queue number (qnum)" rules={[{ required: true }]}>
|
||||
<Input placeholder="e.g. 0" type="number" />
|
||||
</Form.Item>
|
||||
<Form.Item name="service_name" label="Service name (optional)">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="extra_args" label="Extra args (optional)">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="enable_at_boot" valuePropName="checked">
|
||||
<Input type="checkbox" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setEnableModalVisible(false);
|
||||
enableForm.resetFields();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Enable
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Requirements Editor Modal */}
|
||||
{/* Requirements Editor Modal (per-script) */}
|
||||
<Modal
|
||||
open={reqModalVisible}
|
||||
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>
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user