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

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

View File

@@ -494,10 +494,6 @@ async def upload_script(
logger.warning("Invalid name provided: %s", name)
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)

View File

@@ -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>
);

View File

@@ -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>
);
}