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)
|
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)
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user