test scripting changes
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 9s

This commit is contained in:
2026-03-01 01:21:27 +01:00
parent 8fe9a06bfa
commit caf6799dd2
5 changed files with 474 additions and 158 deletions

View File

@@ -20,6 +20,7 @@ import {
Spin,
Table,
Tooltip,
Typography,
Upload,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
@@ -35,12 +36,36 @@ import {
uploadScript,
} from '../api/apiClient';
import PythonEditor from '../components/PythonEditor';
import { EnableRequest, ScriptInfo } from '../types/scripting';
import {
DeleteResult,
EnableRequest,
OperationResult,
ScriptInfo,
ScriptUploadResponse,
UnitStatus,
} from '../types/scripting';
const { Paragraph } = Typography;
type Props = {
onOpenInEditor?: (code: string) => void;
};
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) {
const [scripts, setScripts] = useState<ScriptInfo[]>([]);
const [loading, setLoading] = useState(false);
@@ -49,11 +74,19 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
const [editorValue, setEditorValue] = useState<string>('');
const [currentEditingName, setCurrentEditingName] = useState<string | null>(null);
const [fetchingStatuses, setFetchingStatuses] = useState(false);
const [statuses, setStatuses] = useState<Record<string, UnitStatus> | null>(null);
// pip output modal
const [pipModalVisible, setPipModalVisible] = useState(false);
const [pipOutput, setPipOutput] = useState<{ stdout?: string; stderr?: string } | null>(null);
const [form] = Form.useForm();
const [enableForm] = Form.useForm();
useEffect(() => {
refreshList();
// optional: fetch statuses immediately as well
refreshStatuses();
}, []);
async function refreshList() {
@@ -72,9 +105,9 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
setFetchingStatuses(true);
try {
const all = await fetchScriptsStatusAll();
// assume the API returns keyed info matching script names. adapt as needed
// For simplicity, re-fetch list (which may include status), but if you have separate structure, merge.
await refreshList();
setStatuses(all);
const count = all ? Object.keys(all).length : 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 {
@@ -82,29 +115,45 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
}
}
// Helper: collect UnitMapping[] for a given script name from statuses
const getMappingsForScript = (scriptName: string): UnitMapping[] => {
if (!statuses) return [];
const mappings: UnitMapping[] = [];
for (const svcKey of Object.keys(statuses)) {
const us = statuses[svcKey];
const parsed = us?.parsed as any;
// Prefer matching parsed.name if present, otherwise infer by service string
const parsedName = parsed?.name ?? null;
if (parsedName === scriptName || svcKey.includes(`-${scriptName}-q`)) {
mappings.push({
service: svcKey,
parsed: {
service: parsed?.service ?? svcKey,
exec_start: parsed?.exec_start ?? null,
name: parsed?.name ?? null,
script_path: parsed?.script_path ?? null,
qnum: typeof parsed?.qnum === 'number' ? parsed.qnum : null,
extra: parsed?.extra ?? null,
},
active: !!us?.active,
});
}
}
// sort by qnum (nulls last)
mappings.sort((a, b) => {
const aq = a.parsed.qnum;
const bq = b.parsed.qnum;
if (aq == null && bq == null) return 0;
if (aq == null) return 1;
if (bq == null) return -1;
return aq - bq;
});
return mappings;
};
async function handleDownload(name: string) {
try {
const res = await downloadScript(name);
// Expecting server returns { filename?: string, content?: string/base64 } or actual text
// If server returns raw script text, just download it as blob
let blob: Blob;
if (typeof res === 'string') {
blob = new Blob([res], { type: 'text/x-python' });
} else if (res && res.content && res.filename) {
// maybe base64
try {
const b = atob(res.content);
const arr = new Uint8Array(b.length);
for (let i = 0; i < b.length; i++) arr[i] = b.charCodeAt(i);
blob = new Blob([arr], { type: 'text/x-python' });
} catch {
blob = new Blob([res.content], { type: 'text/x-python' });
}
} else {
// fallback: JSON
blob = new Blob([JSON.stringify(res, null, 2)], { type: 'application/json' });
}
const blob = await downloadScript(name); // Blob
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -121,14 +170,49 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
async function handleDelete(name: string) {
try {
await deleteScript(name);
const res: DeleteResult = await deleteScript(name);
notification.success({ message: 'Deleted', description: `${name} removed` });
if (res.errors && res.errors.length > 0) {
Modal.info({
title: 'Delete completed with errors',
content: (
<div>
<Paragraph>Errors occurred while deleting resources:</Paragraph>
<pre style={{ maxHeight: 240, overflow: 'auto' }}>{res.errors.join('\n')}</pre>
</div>
),
});
}
refreshList();
refreshStatuses();
} catch (err: any) {
notification.error({ message: 'Delete failed', description: err?.message || String(err) });
}
}
async function handleDisableInstance(scriptName: string, mapping: UnitMapping) {
const qnum = mapping.parsed.qnum;
if (typeof qnum !== 'number') {
notification.warning({ message: 'Cannot disable instance', description: 'qnum not available for this unit' });
return;
}
Modal.confirm({
title: `Disable ${mapping.service}?`,
content: `This will attempt to stop and remove unit ${mapping.service} (qnum ${qnum}).`,
onOk: async () => {
try {
const res: OperationResult = await disableScript(scriptName, qnum);
notification.success({ message: 'Disabled', description: `${mapping.service} disabled` });
// refresh both lists and statuses
await refreshStatuses();
await refreshList();
} catch (err: any) {
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
}
},
});
}
function fileToBlob(file: File | Blob) {
return file;
}
@@ -148,16 +232,24 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
const req = requirementsFile?.file || null;
try {
await uploadScript({
const resp: ScriptUploadResponse = await uploadScript({
name,
script,
requirements: req,
});
notification.success({ message: 'Uploaded', description: `${name} uploaded` });
if (resp.pip && (resp.pip.stdout || resp.pip.stderr)) {
setPipOutput(resp.pip);
setPipModalVisible(true);
}
setUploadModalVisible(false);
form.resetFields();
setCurrentEditingName(null);
refreshList();
refreshStatuses();
} catch (err: any) {
notification.error({ message: 'Upload failed', description: err?.message || String(err) });
}
@@ -165,19 +257,8 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
async function openInEditorFromServer(name: string) {
try {
const res = await downloadScript(name);
let text = '';
if (typeof res === 'string') text = res;
else if (res && res.content) {
// maybe base64
try {
text = atob(res.content);
} catch {
text = typeof res.content === 'string' ? res.content : JSON.stringify(res.content);
}
} else {
text = JSON.stringify(res);
}
const blob = await downloadScript(name); // Blob
const text = await blob.text();
setEditorValue(text);
setEditorModalVisible(true);
setCurrentEditingName(name);
@@ -189,14 +270,20 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
async function handleSaveFromEditorAs(name: string) {
try {
// create blob from editor and upload
await uploadScript({
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);
}
setEditorModalVisible(false);
refreshList();
refreshStatuses();
} catch (err: any) {
notification.error({ message: 'Save failed', description: err?.message || String(err) });
}
@@ -205,7 +292,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
// enable modal/form
const [enableModalVisible, setEnableModalVisible] = useState(false);
const [enableTarget, setEnableTarget] = useState<string | null>(null);
const [enableForm] = Form.useForm();
async function handleEnableSubmit(values: any) {
if (!enableTarget) return;
@@ -216,12 +302,13 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
enable_at_boot: !!values.enable_at_boot,
};
try {
await enableScript(enableTarget, req);
notification.success({ message: 'Enabled', description: `${enableTarget} enabled` });
const res: OperationResult = await enableScript(enableTarget, req);
notification.success({ message: 'Enabled', description: `${enableTarget} enabled as ${res.service}` });
setEnableModalVisible(false);
setEnableTarget(null);
enableForm.resetFields();
refreshList();
refreshStatuses();
} catch (err: any) {
notification.error({ message: 'Enable failed', description: err?.message || String(err) });
}
@@ -229,16 +316,18 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
async function handleDisable(name: string) {
try {
// ask for qnum? assume qnum required; if you know qnum mapping show UI to input; here we default to 0 if not known
// if you prefer, open a modal to ask qnum — keeping default 0 for backward compatibility
const qnum = 0;
await disableScript(name, qnum);
notification.success({ message: 'Disabled', description: `${name} disabled` });
const res: OperationResult = await disableScript(name, qnum);
notification.success({ message: 'Disabled', description: `${name} disabled (service ${res.service})` });
refreshList();
refreshStatuses();
} catch (err: any) {
notification.error({ message: 'Disable failed', description: err?.message || String(err) });
}
}
// Table columns for main script list
const columns: ColumnsType<ScriptInfo> = [
{
title: 'Name',
@@ -255,6 +344,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
{
title: 'Actions',
key: 'actions',
width: 320,
render: (_, record) => (
<Space>
<Tooltip title="Download">
@@ -272,7 +362,7 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
}}
/>
</Tooltip>
<Tooltip title="Disable">
<Tooltip title="Disable (defaults to qnum=0)">
<Button icon={<PauseCircleOutlined />} onClick={() => handleDisable(record.name)} />
</Tooltip>
<Popconfirm title={`Delete ${record.name}?`} onConfirm={() => handleDelete(record.name)}>
@@ -283,6 +373,67 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
},
];
// nested columns for unit mappings when a script row is expanded
const nestedColumns = [
{
title: 'Service',
dataIndex: ['parsed', 'service'],
key: 'service',
render: (_: any, r: UnitMapping) => <code>{r.service}</code>,
},
{ 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 || '-' },
{
title: 'ExecStart',
dataIndex: ['parsed', 'exec_start'],
key: 'exec_start',
render: (s: string | null) => s || '-',
},
{
title: 'Actions',
key: 'unit_actions',
render: (_: any, r: UnitMapping) => (
<Space>
<Button
onClick={() => handleDisableInstance(r.parsed.name ?? r.parsed.service ?? '', r)}
danger
icon={<PauseCircleOutlined />}
disabled={typeof r.parsed.qnum !== 'number'}
>
Disable instance
</Button>
</Space>
),
},
];
// Table expandable config
const expandable = {
expandedRowRender: (record: ScriptInfo) => {
const mappings = getMappingsForScript(record.name);
if (!mappings || mappings.length === 0) {
return <div style={{ padding: 12 }}>No running instances for this script.</div>;
}
return (
<Table
columns={nestedColumns}
dataSource={mappings}
pagination={false}
rowKey={(r: UnitMapping) => r.service}
size="small"
/>
);
},
rowExpandable: (record: ScriptInfo) => {
const mappings = getMappingsForScript(record.name);
return mappings.length > 0;
},
defaultExpandAllRows: true,
// show expand icon in first column
expandRowByClick: false,
};
return (
<div>
<Row justify="space-between" style={{ marginBottom: 12 }}>
@@ -293,11 +444,32 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
</Button>
<Button onClick={refreshList}>Refresh</Button>
<Button onClick={refreshStatuses}>{fetchingStatuses ? <Spin size="small" /> : 'Statuses'}</Button>
{statuses && (
<Button
onClick={() =>
Modal.info({
title: 'Status summary',
content: (
<pre style={{ maxHeight: 300, overflow: 'auto' }}>{JSON.stringify(statuses, null, 2)}</pre>
),
})
}
>
View Raw Status
</Button>
)}
</Space>
</Col>
</Row>
<Table rowKey="name" columns={columns} dataSource={scripts} loading={loading} pagination={{ pageSize: 10 }} />
<Table
rowKey="name"
columns={columns}
dataSource={scripts}
loading={loading}
pagination={{ pageSize: 10 }}
expandable={expandable}
/>
{/* Upload Modal */}
<Modal
@@ -364,7 +536,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
<Button
type="default"
onClick={() => {
// Save as new name
Modal.confirm({
title: 'Save as',
content: (
@@ -396,7 +567,6 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
<Button
type="primary"
onClick={() => {
// overwrite current
if (!currentEditingName) {
notification.warning({ message: 'No filename to overwrite' });
return;
@@ -453,6 +623,25 @@ export default function ScriptsManager({ onOpenInEditor }: Props) {
</Form.Item>
</Form>
</Modal>
{/* Pip output modal */}
<Modal
open={pipModalVisible}
title="pip install output"
onCancel={() => {
setPipModalVisible(false);
setPipOutput(null);
}}
footer={<Button onClick={() => setPipModalVisible(false)}>Close</Button>}
width={800}
>
<div style={{ maxHeight: 420, overflow: 'auto' }}>
<h4>STDOUT</h4>
<pre style={{ whiteSpace: 'pre-wrap' }}>{pipOutput?.stdout ?? '(none)'}</pre>
<h4>STDERR</h4>
<pre style={{ whiteSpace: 'pre-wrap', color: 'red' }}>{pipOutput?.stderr ?? '(none)'}</pre>
</div>
</Modal>
</div>
);
}