test scripting changes
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:
@@ -9,7 +9,15 @@ import {
|
||||
InterfaceInfo,
|
||||
RouteInfo,
|
||||
} from '../types/network';
|
||||
import { EnableRequest, ScriptInfo } from '../types/scripting';
|
||||
import {
|
||||
DeleteResult,
|
||||
EnableRequest,
|
||||
OperationResult,
|
||||
ScriptInfo,
|
||||
ScriptUploadResponse,
|
||||
StatusForNameResponse,
|
||||
UnitStatus,
|
||||
} from '../types/scripting';
|
||||
import { SnifferStatusResponse } from '../types/sniffer';
|
||||
|
||||
const BASE = 'http://mitm.lan/api';
|
||||
@@ -17,7 +25,7 @@ const BASE = 'http://mitm.lan/api';
|
||||
export const api = axios.create({
|
||||
baseURL: BASE,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 10000,
|
||||
timeout: 20000,
|
||||
});
|
||||
|
||||
// Normalize FastAPI errors here
|
||||
@@ -160,13 +168,13 @@ export const execFirewallRaw = async (cmd: string): Promise<ExecResult> => {
|
||||
Scripts
|
||||
------------------------- */
|
||||
|
||||
export const fetchScriptsStatusAll = async (): Promise<any> => {
|
||||
const res = await api.get('/scripts/scripts/status');
|
||||
export const fetchScriptsStatusAll = async (): Promise<Record<string, UnitStatus>> => {
|
||||
const res = await api.get<Record<string, UnitStatus>>('/scripts/scripts/status');
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const fetchScriptStatusForName = async (name: string): Promise<any> => {
|
||||
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}/status`);
|
||||
export const fetchScriptStatusForName = async (name: string): Promise<StatusForNameResponse> => {
|
||||
const res = await api.get<StatusForNameResponse>(`/scripts/scripts/${encodeURIComponent(name)}/status`);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
@@ -179,38 +187,40 @@ export const uploadScript = async (opts: {
|
||||
name: string;
|
||||
script: File | Blob;
|
||||
requirements?: File | Blob | null;
|
||||
}): Promise<ScriptInfo> => {
|
||||
}): Promise<ScriptUploadResponse> => {
|
||||
const fd = new FormData();
|
||||
fd.append('name', opts.name);
|
||||
fd.append('script', opts.script);
|
||||
if (opts.requirements) fd.append('requirements', opts.requirements as Blob);
|
||||
|
||||
// axios will set multipart/form-data boundary automatically when FormData passed
|
||||
const res = await api.post<ScriptInfo>('/scripts/scripts', fd, {
|
||||
const res = await api.post<ScriptUploadResponse>('/scripts/scripts', fd, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const downloadScript = async (name: string): Promise<any> => {
|
||||
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`);
|
||||
return res.data;
|
||||
export const downloadScript = async (name: string): Promise<Blob> => {
|
||||
// expect the backend to return a FileResponse. request as blob to trigger browser download handling.
|
||||
const res = await api.get(`/scripts/scripts/${encodeURIComponent(name)}`, { responseType: 'blob' });
|
||||
return res.data as Blob;
|
||||
};
|
||||
|
||||
export const deleteScript = async (name: string, qnum?: number | null): Promise<any> => {
|
||||
export const deleteScript = async (name: string, qnum?: number | null): Promise<DeleteResult> => {
|
||||
const params: Record<string, any> = {};
|
||||
if (typeof qnum !== 'undefined') params.qnum = qnum;
|
||||
const res = await api.delete(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
|
||||
if (typeof qnum !== 'undefined' && qnum !== null) params.qnum = qnum;
|
||||
const res = await api.delete<DeleteResult>(`/scripts/scripts/${encodeURIComponent(name)}`, { params });
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const enableScript = async (name: string, req: EnableRequest): Promise<any> => {
|
||||
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
|
||||
export const enableScript = async (name: string, req: EnableRequest): Promise<OperationResult> => {
|
||||
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/enable`, req);
|
||||
return res.data;
|
||||
};
|
||||
|
||||
export const disableScript = async (name: string, qnum: number): Promise<any> => {
|
||||
const res = await api.post(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
|
||||
export const disableScript = async (name: string, qnum: number): Promise<OperationResult> => {
|
||||
// backend expects qnum as query param (POST body null)
|
||||
const res = await api.post<OperationResult>(`/scripts/scripts/${encodeURIComponent(name)}/disable`, null, {
|
||||
params: { qnum },
|
||||
});
|
||||
return res.data;
|
||||
|
||||
@@ -11,6 +11,7 @@ export type PythonEditorProps = {
|
||||
readOnly?: boolean;
|
||||
className?: string;
|
||||
height?: string | number; // e.g. '200px' or 200
|
||||
wrap?: boolean; // if true, long lines will wrap; default false (no wrap)
|
||||
};
|
||||
|
||||
export default function PythonEditor({
|
||||
@@ -19,6 +20,7 @@ export default function PythonEditor({
|
||||
readOnly = false,
|
||||
height = 480,
|
||||
className = '',
|
||||
wrap = false,
|
||||
}: PythonEditorProps) {
|
||||
const highlight = useCallback((code: string) => {
|
||||
try {
|
||||
@@ -28,22 +30,43 @@ export default function PythonEditor({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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(
|
||||
() => ({
|
||||
fontFamily: '"JetBrains Mono", "Fira Code", monospace',
|
||||
fontSize: 14,
|
||||
height: typeof height === 'number' ? `${height}px` : height,
|
||||
// make editor fill the container (pre/textarea inside will take up height)
|
||||
minHeight: '100%',
|
||||
boxSizing: 'border-box' as const,
|
||||
padding: 12,
|
||||
outline: 'none',
|
||||
whiteSpace: wrap ? 'pre-wrap' : 'pre', // pre-wrap if wrap requested
|
||||
wordBreak: 'break-word' as const,
|
||||
}),
|
||||
[height],
|
||||
[wrap],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`python-editor ${className}`}>
|
||||
<div className={`python-editor ${className}`} style={containerStyle}>
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={(code) => onChange(code)}
|
||||
highlight={highlight}
|
||||
padding={12}
|
||||
padding={0} // padding handled by editorStyle
|
||||
preClassName="language-python"
|
||||
textareaId="python-editor"
|
||||
style={editorStyle}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,9 +3,58 @@ export type ScriptInfo = {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ScriptUploadResponse = ScriptInfo & {
|
||||
pip?: {
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type UnitParsed = {
|
||||
service: string;
|
||||
exec_start?: string | null;
|
||||
name?: string | null;
|
||||
script_path?: string | null;
|
||||
qnum?: number | null;
|
||||
extra?: string | null;
|
||||
};
|
||||
|
||||
export type UnitStatus = {
|
||||
parsed: UnitParsed;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type UnitMapping = {
|
||||
service: string;
|
||||
parsed: UnitParsed;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type StatusForNameResponse = {
|
||||
name: string;
|
||||
mappings: UnitMapping[];
|
||||
};
|
||||
|
||||
export type OperationResult = {
|
||||
status: string;
|
||||
name: string;
|
||||
qnum?: number | null;
|
||||
service?: string | null;
|
||||
python?: string | null;
|
||||
};
|
||||
|
||||
export type DeleteResult = {
|
||||
name: string;
|
||||
units_removed: string[];
|
||||
units_failed: string[];
|
||||
files_removed: string[];
|
||||
files_failed: string[];
|
||||
errors: string[];
|
||||
};
|
||||
|
||||
export type EnableRequest = {
|
||||
qnum: number;
|
||||
service_name?: string | null;
|
||||
extra_args?: string | null;
|
||||
enable_at_boot?: boolean | null;
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user