diff --git a/frontend/src/components/FirewallRulesetViewer.tsx b/frontend/src/components/FirewallRulesetViewer.tsx
index 4d9200a..57fc057 100644
--- a/frontend/src/components/FirewallRulesetViewer.tsx
+++ b/frontend/src/components/FirewallRulesetViewer.tsx
@@ -292,7 +292,7 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
{tablesToRender.length === 0 && (
@@ -310,7 +310,7 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
- Table "{table.name}"
+ Table {table.name}
Family: {table.family ?? 'unknown'} Chains: {table.chains.length} {' '}
@@ -405,6 +405,7 @@ export default function FirewallTables({ tables, error, refreshRules: refresh }:
}
onClick={() => handleDeleteChain(table.family, table.name, chain.name)}
diff --git a/frontend/src/components/ScriptManager.tsx b/frontend/src/components/ScriptManager.tsx
new file mode 100644
index 0000000..e749227
--- /dev/null
+++ b/frontend/src/components/ScriptManager.tsx
@@ -0,0 +1,458 @@
+import {
+ DeleteOutlined,
+ DownloadOutlined,
+ EditOutlined,
+ PauseCircleOutlined,
+ PlayCircleOutlined,
+ PlusOutlined,
+ UploadOutlined,
+} from '@ant-design/icons';
+import {
+ Button,
+ Col,
+ Form,
+ Input,
+ Modal,
+ notification,
+ Popconfirm,
+ Row,
+ Space,
+ Spin,
+ Table,
+ Tooltip,
+ Upload,
+} from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+import { useEffect, useState } from 'react';
+
+import {
+ deleteScript,
+ disableScript,
+ downloadScript,
+ enableScript,
+ fetchScriptsStatusAll,
+ listScripts,
+ uploadScript,
+} from '../api/apiClient';
+import PythonEditor from '../components/PythonEditor';
+import { EnableRequest, ScriptInfo } from '../types/scripting';
+
+type Props = {
+ onOpenInEditor?: (code: string) => void;
+};
+
+export default function ScriptsManager({ onOpenInEditor }: Props) {
+ const [scripts, setScripts] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [uploadModalVisible, setUploadModalVisible] = useState(false);
+ const [editorModalVisible, setEditorModalVisible] = useState(false);
+ const [editorValue, setEditorValue] = useState('');
+ const [currentEditingName, setCurrentEditingName] = useState(null);
+ const [fetchingStatuses, setFetchingStatuses] = useState(false);
+
+ const [form] = Form.useForm();
+
+ useEffect(() => {
+ refreshList();
+ }, []);
+
+ async function refreshList() {
+ setLoading(true);
+ try {
+ const data = await listScripts();
+ setScripts(data || []);
+ } catch (err: any) {
+ notification.error({ message: 'Failed to list scripts', description: err?.message || String(err) });
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ async function refreshStatuses() {
+ 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();
+ } catch (err: any) {
+ notification.error({ message: 'Failed to fetch statuses', description: err?.message || String(err) });
+ } finally {
+ setFetchingStatuses(false);
+ }
+ }
+
+ 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 url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = name.endsWith('.py') ? name : `${name}.py`;
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ URL.revokeObjectURL(url);
+ notification.success({ message: 'Download started' });
+ } catch (err: any) {
+ notification.error({ message: 'Download failed', description: err?.message || String(err) });
+ }
+ }
+
+ async function handleDelete(name: string) {
+ try {
+ await deleteScript(name);
+ notification.success({ message: 'Deleted', description: `${name} removed` });
+ refreshList();
+ } catch (err: any) {
+ notification.error({ message: 'Delete failed', description: err?.message || String(err) });
+ }
+ }
+
+ function fileToBlob(file: File | Blob) {
+ return file;
+ }
+
+ async function handleUpload(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;
+
+ try {
+ await uploadScript({
+ name,
+ script,
+ requirements: req,
+ });
+ notification.success({ message: 'Uploaded', description: `${name} uploaded` });
+ setUploadModalVisible(false);
+ form.resetFields();
+ setCurrentEditingName(null);
+ refreshList();
+ } catch (err: any) {
+ notification.error({ message: 'Upload failed', description: err?.message || String(err) });
+ }
+ }
+
+ 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);
+ }
+ setEditorValue(text);
+ setEditorModalVisible(true);
+ setCurrentEditingName(name);
+ if (onOpenInEditor) onOpenInEditor(text);
+ } catch (err: any) {
+ notification.error({ message: 'Failed to open', description: err?.message || String(err) });
+ }
+ }
+
+ async function handleSaveFromEditorAs(name: string) {
+ try {
+ // create blob from editor and upload
+ await uploadScript({
+ name,
+ script: new Blob([editorValue], { type: 'text/x-python' }),
+ });
+ notification.success({ message: 'Saved', description: `${name} uploaded` });
+ setEditorModalVisible(false);
+ refreshList();
+ } catch (err: any) {
+ notification.error({ message: 'Save failed', description: err?.message || String(err) });
+ }
+ }
+
+ // enable modal/form
+ const [enableModalVisible, setEnableModalVisible] = useState(false);
+ const [enableTarget, setEnableTarget] = useState(null);
+ const [enableForm] = Form.useForm();
+
+ async function handleEnableSubmit(values: any) {
+ if (!enableTarget) return;
+ const req: EnableRequest = {
+ qnum: Number(values.qnum),
+ service_name: values.service_name || null,
+ extra_args: values.extra_args || null,
+ enable_at_boot: !!values.enable_at_boot,
+ };
+ try {
+ await enableScript(enableTarget, req);
+ notification.success({ message: 'Enabled', description: `${enableTarget} enabled` });
+ setEnableModalVisible(false);
+ setEnableTarget(null);
+ enableForm.resetFields();
+ refreshList();
+ } catch (err: any) {
+ notification.error({ message: 'Enable failed', description: err?.message || String(err) });
+ }
+ }
+
+ 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
+ const qnum = 0;
+ await disableScript(name, qnum);
+ notification.success({ message: 'Disabled', description: `${name} disabled` });
+ refreshList();
+ } catch (err: any) {
+ notification.error({ message: 'Disable failed', description: err?.message || String(err) });
+ }
+ }
+
+ const columns: ColumnsType = [
+ {
+ title: 'Name',
+ dataIndex: 'name',
+ key: 'name',
+ render: (text) => {text},
+ },
+ {
+ title: 'Path',
+ dataIndex: 'path',
+ key: 'path',
+ render: (p) => p || '-',
+ },
+ {
+ title: 'Actions',
+ key: 'actions',
+ render: (_, record) => (
+
+
+ } onClick={() => handleDownload(record.name)} />
+
+
+ } onClick={() => openInEditorFromServer(record.name)} />
+
+
+ }
+ onClick={() => {
+ setEnableTarget(record.name);
+ setEnableModalVisible(true);
+ }}
+ />
+
+
+ } onClick={() => handleDisable(record.name)} />
+
+ handleDelete(record.name)}>
+ } />
+
+
+ ),
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/Scripting.tsx b/frontend/src/pages/Scripting.tsx
index 18bd14f..a0364f9 100644
--- a/frontend/src/pages/Scripting.tsx
+++ b/frontend/src/pages/Scripting.tsx
@@ -1,31 +1,46 @@
import { Card, Tabs } from 'antd';
-import { useState } from 'react';
+import React, { useRef, useState } from 'react';
import PythonEditor from '../components/PythonEditor';
+import ScriptsManager from '../components/ScriptManager';
const TwoTabsPage: React.FC = () => {
+ // Editor state: when a saved script is "opened" from the list, we set this
const [code, setCode] = useState("print('hello world')\n");
-
- const tabItems = [
- {
- key: '1',
- label: 'Saved Scripts',
- children: <>>,
- },
- {
- key: '2',
- label: 'Custom Script',
- children: (
-
- ),
- },
- ];
+ // A way for ScriptsManager to open a script into the editor:
+ const editorSetRef = useRef<(code: string) => void>(() => {});
return (
-
+ {
+ setCode(scriptCode);
+ // switch to editor tab programmatically? keep simple — user can switch
+ }}
+ />
+ ),
+ },
+ {
+ key: '2',
+ label: 'Custom Script',
+ children: (
+
+
Python Editor
+
+ {/* Buttons could be added here for "Run", "Save", "Upload" etc. */}
+
+
+
+ ),
+ },
+ ]}
+ />
);
};