restructure frontend and add network settings
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m41s

This commit is contained in:
2025-12-18 20:38:27 +01:00
parent b0de61fd9d
commit 9afe49857a
15 changed files with 1479 additions and 696 deletions

View File

@@ -12,18 +12,16 @@ export default function App() {
const [collapsed, setCollapsed] = useState(false);
return (
<>
<Layout style={{ minHeight: '100vh' }}>
<AppHeader />
<Layout>
<Sidebar collapsed={collapsed} onCollapse={setCollapsed} />
<Layout style={{ padding: '24px' }}>
<Content style={{ minHeight: 'calc(100vh - 64px)' }}>
<Outlet />
</Content>
</Layout>
<Layout className="app-root">
<Sidebar collapsed={collapsed} onCollapse={setCollapsed} />
<Layout className="app-main">
<AppHeader />
<Content>
<Outlet />
</Content>
<AppFooter />
</Layout>
</Layout>
<AppFooter />
</>
);
}

View File

@@ -1,14 +1,16 @@
// src/components/Sidebar.tsx
import { HomeOutlined, InfoCircleOutlined, SettingOutlined } from '@ant-design/icons';
import { ApartmentOutlined, HomeOutlined, InfoCircleOutlined, SettingOutlined } from '@ant-design/icons';
import { Layout, Menu } from 'antd';
import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { PATHS } from '../routes';
import '../theme/layout.less';
const { Sider } = Layout;
const menuItems = [
{ key: '/', icon: <HomeOutlined />, label: 'Home' },
{ key: PATHS.HOME, icon: <HomeOutlined />, label: 'Home' },
{ key: PATHS.NETWORK, icon: <ApartmentOutlined />, label: 'Network' },
{ key: '/about', icon: <InfoCircleOutlined />, label: 'About' },
{ key: '/settings', icon: <SettingOutlined />, label: 'Settings' },
];

View File

@@ -0,0 +1,55 @@
// src/apiClient.ts
import axios from "axios";
import {
BridgeCreateRequest,
BridgeInfo,
BridgeRemoveRequest,
FullState,
InterfaceInfo,
RouteInfo,
} from "../types/network";
const BASE = "http://mitm.lan/api";
export const api = axios.create({
baseURL: BASE,
headers: { "Content-Type": "application/json" },
timeout: 10000,
});
// Queries
export const fetchInterfaces = async (): Promise<InterfaceInfo[]> => {
const res = await api.get<InterfaceInfo[]>("/network/interfaces");
return res.data;
};
export const fetchLinks = async (): Promise<InterfaceInfo[]> => {
const res = await api.get<InterfaceInfo[]>("/network/links");
return res.data;
};
export const fetchRoutes = async (): Promise<RouteInfo[]> => {
const res = await api.get<RouteInfo[]>("/network/routes");
return res.data;
};
export const fetchBridges = async (): Promise<BridgeInfo[]> => {
const res = await api.get<BridgeInfo[]>("/network/bridges");
return res.data;
};
export const fetchFullState = async (): Promise<FullState> => {
const res = await api.get<FullState>("/network/full-state");
return res.data;
};
// Mutations
export const createBridge = async (req: BridgeCreateRequest) => {
const res = await api.post("network/bridge/create", req);
return res.data;
};
export const removeBridge = async (req: BridgeRemoveRequest) => {
const res = await api.post("network/bridge/remove", req);
return res.data;
};

View File

@@ -0,0 +1,15 @@
// src/queryClient.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 20, // 20s
cacheTime: 1000 * 60 * 5,
refetchOnWindowFocus: false,
},
mutations: {
// default mutation behaviour
},
},
});

View File

@@ -0,0 +1,35 @@
// src/AppRouter.tsx
import { Navigate, Route, Routes } from 'react-router-dom';
import App from './App'; // your layout component (has <Outlet />)
import Bridges from './pages/Bridges';
import Home from './pages/Home';
import { PATHS } from './routes';
export default function AppRouter() {
return (
<Routes>
{/* App is the top-level layout; Outlet renders the active child route */}
<Route path={PATHS.ROOT} element={<App />}>
{/* When the user hits '/', redirect to '/home' */}
<Route index element={<Navigate to={PATHS.HOME} replace />} />
{/* Child routes - these render inside App's <Outlet /> */}
<Route path={PATHS.HOME.slice(1)} element={<Home />} />
<Route path={PATHS.NETWORK.slice(1)} element={<Bridges />} />
{/* Fallback (renders inside layout too) */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
);
}
/** simple 404 rendered inside the layout */
function NotFound() {
return (
<div style={{ padding: 16 }}>
<h2>404 – Not Found</h2>
<p>The requested page does not exist.</p>
</div>
);
}

View File

@@ -0,0 +1,227 @@
// src/hooks/useNetwork.ts
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useCallback, useState } from "react";
import * as api from "../api/apiClient";
import type {
BridgeCreateRequest,
BridgeInfo,
BridgeRemoveRequest,
FullState,
InterfaceInfo,
RouteInfo,
} from "../types/network";
/**
* useNetwork
*
* - queries start disabled (no automatic network calls)
* - calling fetchInterfaces()/fetchBridges()/... will:
* 1) fetch and cache the data right away (queryClient.fetchQuery)
* 2) enable the corresponding useQuery so it becomes "active" and will
* auto-refetch based on the query options
*
* This gives "no initial auto-fetch" but "once fetched, auto-updates".
*/
export function useNetwork() {
const qc = useQueryClient();
// per-query enabled flags (start false => no automatic fetch)
const [interfacesEnabled, setInterfacesEnabled] = useState(false);
const [linksEnabled, setLinksEnabled] = useState(false);
const [routesEnabled, setRoutesEnabled] = useState(false);
const [bridgesEnabled, setBridgesEnabled] = useState(false);
const [fullStateEnabled, setFullStateEnabled] = useState(false);
// common query options once enabled
const commonOptions = {
refetchOnWindowFocus: true,
staleTime: 1000 * 10, // 10s
};
// Queries (disabled initially)
const interfacesQuery = useQuery<InterfaceInfo[]>({
queryKey: ["interfaces"],
queryFn: api.fetchInterfaces,
enabled: interfacesEnabled,
...commonOptions,
});
const linksQuery = useQuery<InterfaceInfo[]>({
queryKey: ["links"],
queryFn: api.fetchLinks,
enabled: linksEnabled,
...commonOptions,
});
const routesQuery = useQuery<RouteInfo[]>({
queryKey: ["routes"],
queryFn: api.fetchRoutes,
enabled: routesEnabled,
...commonOptions,
});
const bridgesQuery = useQuery<BridgeInfo[]>({
queryKey: ["bridges"],
queryFn: api.fetchBridges,
enabled: bridgesEnabled,
...commonOptions,
});
const fullStateQuery = useQuery<FullState>({
queryKey: ["full-state"],
queryFn: api.fetchFullState,
enabled: fullStateEnabled,
...commonOptions,
});
// Imperative fetch helpers that also enable auto-refetch behavior
const fetchInterfaces = useCallback(async () => {
const res = await qc.fetchQuery<InterfaceInfo[]>({
queryKey: ["interfaces"],
queryFn: api.fetchInterfaces,
staleTime: 1000 * 10
});
setInterfacesEnabled(true);
return res;
}, [qc]);
const fetchLinks = useCallback(async () => {
const res = await qc.fetchQuery<InterfaceInfo[]>({
queryKey: ["links"],
queryFn: api.fetchLinks,
staleTime: 1000 * 10
});
setLinksEnabled(true);
return res;
}, [qc]);
const fetchRoutes = useCallback(async () => {
const res = await qc.fetchQuery<RouteInfo[]>({
queryKey: ["routes"],
queryFn: api.fetchRoutes,
staleTime: 1000 * 10
});
setRoutesEnabled(true);
return res;
}, [qc]);
const fetchBridges = useCallback(async () => {
const res = await qc.fetchQuery<BridgeInfo[]>({
queryKey: ["bridges"],
queryFn: api.fetchBridges,
staleTime: 1000 * 10
});
setBridgesEnabled(true);
return res;
}, [qc]);
const fetchFullState = useCallback(async () => {
const res = await qc.fetchQuery<FullState>({
queryKey: ["full-state"],
queryFn: api.fetchFullState,
staleTime: 1000 * 5
});
setFullStateEnabled(true);
return res;
}, [qc]);
// Local loading state for simple UI feedback
const [isCreating, setIsCreating] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
// Simple imperative functions that call the API and invalidate queries
async function createBridge(payload: BridgeCreateRequest) {
setIsCreating(true);
try {
await api.createBridge(payload);
// If the query is enabled it will refetch automatically after invalidation.
await qc.invalidateQueries({ queryKey: ["bridges"] });
await qc.invalidateQueries({ queryKey: ["full-state"] });
await qc.invalidateQueries({ queryKey: ["interfaces"] });
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "Create bridge failed";
throw new Error(message);
} finally {
setIsCreating(false);
}
}
async function removeBridge(payload: BridgeRemoveRequest) {
setIsRemoving(true);
try {
await api.removeBridge(payload);
await qc.invalidateQueries({ queryKey: ["bridges"] });
await qc.invalidateQueries({ queryKey: ["full-state"] });
await qc.invalidateQueries({ queryKey: ["interfaces"] });
} catch (err) {
const message =
err instanceof Error ? err.message : typeof err === "string" ? err : "Remove bridge failed";
throw new Error(message);
} finally {
setIsRemoving(false);
}
}
// Convenience: invalidate helpers
function refreshInterfaces() {
return qc.invalidateQueries({ queryKey: ["interfaces"] });
}
function refreshLinks() {
return qc.invalidateQueries({ queryKey: ["links"] });
}
function refreshRoutes() {
return qc.invalidateQueries({ queryKey: ["routes"] });
}
function refreshBridges() {
return qc.invalidateQueries({ queryKey: ["bridges"] });
}
function refreshFullState() {
return qc.invalidateQueries({ queryKey: ["full-state"] });
}
// Convenience: refresh all queries
function refreshAll() {
refreshInterfaces();
refreshLinks();
refreshRoutes();
refreshBridges();
refreshFullState();
}
return {
// queries
interfacesQuery,
linksQuery,
routesQuery,
bridgesQuery,
fullStateQuery,
// manual fetchers (fetch+enable auto-updates)
fetchInterfaces,
fetchLinks,
fetchRoutes,
fetchBridges,
fetchFullState,
// simple mutation functions (imperative)
createBridge,
removeBridge,
// local loading flags
isCreating,
isRemoving,
// invalidate helpers
refreshInterfaces,
refreshLinks,
refreshRoutes,
refreshBridges,
refreshFullState,
refreshAll,
};
}

View File

@@ -1,20 +1,20 @@
import { ConfigProvider } from 'antd';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.js';
import { queryClient } from './api/queryClient.js';
import AppRouter from './appRouter.js';
import './index.less';
import './theme/theme.less';
const rootElement = document.getElementById('root');
if (rootElement) {
createRoot(rootElement).render(
<StrictMode>
<ConfigProvider theme={{}}>
<BrowserRouter>
<App />
</BrowserRouter>
</ConfigProvider>
</StrictMode>,
);
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRouter />
</BrowserRouter>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -0,0 +1,234 @@
// src/pages/BridgesManager.tsx
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { Button, Col, Form, Input, message, Modal, Popconfirm, Row, Select, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { useEffect, useMemo, useState } from 'react';
import { useNetwork } from '../hooks/useNetwork';
import type { BridgeInfo, InterfaceInfo } from '../types/network';
const { Title, Paragraph } = Typography;
export default function NetworkManager() {
const {
interfacesQuery,
bridgesQuery,
createBridge,
removeBridge,
fetchBridges,
fetchInterfaces,
refreshBridges,
refreshInterfaces,
isCreating,
isRemoving,
} = useNetwork();
const [bridgeModalVisible, setBridgeModalVisible] = useState(false);
const [bridgeForm] = Form.useForm();
// fetch on mount (explicit, since queries are disabled by default)
useEffect(() => {
fetchInterfaces().catch(() => {});
fetchBridges().catch(() => {});
}, []);
// Table data
const interfaces = interfacesQuery.data ?? [];
const bridges = bridgesQuery.data ?? [];
// build select options from interfaces list
const interfaceOptions = useMemo(
() =>
interfaces.map((it: InterfaceInfo) => ({
label: it.name,
value: it.name,
})),
[interfaces],
);
// Columns for interfaces table (read-only)
const interfaceColumns: ColumnsType<InterfaceInfo> = useMemo(
() => [
{ title: 'IfIndex', dataIndex: 'ifindex', key: 'ifindex', width: 90 },
{ title: 'Name', dataIndex: 'name', key: 'name' },
{ title: 'State', dataIndex: 'state', key: 'state', render: (s) => <Tag>{s}</Tag> },
{ title: 'MAC', dataIndex: 'mac', key: 'mac', render: (m) => m ?? '—' },
{ title: 'MTU', dataIndex: 'mtu', key: 'mtu', width: 90 },
{
title: 'Addresses',
dataIndex: 'addresses',
key: 'addresses',
render: (addrs: any[]) =>
addrs?.length ? (
<Space direction="vertical">
{addrs.map((a) => (
<span key={`${a.address}/${a.prefixlen}`}>
{a.address}/{a.prefixlen} ({a.family})
</span>
))}
</Space>
) : (
<span>—</span>
),
},
],
[],
);
// Columns for bridges table (with remove action)
const bridgeColumns: ColumnsType<BridgeInfo> = useMemo(
() => [
{ title: 'IfIndex', dataIndex: 'ifindex', key: 'ifindex', width: 90 },
{ title: 'Name', dataIndex: 'ifname', key: 'ifname' },
{ title: 'State', dataIndex: 'state', key: 'state', render: (s) => s ?? '—' },
{
title: 'Members',
dataIndex: 'members',
key: 'members',
render: (members: any[]) => (members?.length ? members.map((m) => m.ifname).join(', ') : '—'),
},
{
title: 'Actions',
key: 'actions',
width: 140,
render: (_: any, record: BridgeInfo) => (
<Popconfirm
title={`Remove bridge ${record.ifname}?`}
onConfirm={() => handleRemoveBridge(record.ifname)}
okText="Remove"
cancelText="Cancel"
>
<Button danger icon={<DeleteOutlined />} loading={isRemoving} size="small">
Remove
</Button>
</Popconfirm>
),
},
],
[isRemoving],
);
async function handleCreateBridge(values: { name: string; interfaces?: string[] }) {
const ifaceList = values.interfaces ?? [];
try {
await createBridge({ name: values.name, interfaces: ifaceList });
message.success(`Bridge ${values.name} created`);
setBridgeModalVisible(false);
bridgeForm.resetFields();
} catch (err) {
console.error(err);
message.error((err as Error)?.message ?? 'Failed to create bridge');
}
}
async function handleRemoveBridge(name: string) {
try {
await removeBridge({ name });
message.success(`Bridge ${name} removed`);
} catch (err) {
console.error(err);
message.error((err as Error)?.message ?? 'Failed to remove bridge');
}
}
return (
<div style={{ padding: 16 }}>
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
<Col>
<Title level={4}>Bridges & Interfaces</Title>
<Paragraph type="secondary">View system interfaces and manage network bridges.</Paragraph>
</Col>
<Col>
<Space>
<Button
icon={<PlusOutlined />}
type="primary"
onClick={() => {
// ensure up-to-date interface list when opening modal
refreshInterfaces();
setBridgeModalVisible(true);
}}
>
Create bridge
</Button>
<Button
icon={<ReloadOutlined />}
onClick={() => {
refreshBridges();
refreshInterfaces();
message.success('Refreshing…');
}}
>
Refresh
</Button>
</Space>
</Col>
</Row>
<Row gutter={16}>
<Col span={14}>
<div style={{ background: '#fff', padding: 12, borderRadius: 6, minHeight: 220 }}>
<Title level={5} style={{ marginBottom: 12 }}>
Interfaces ({interfaces.length})
</Title>
<Table
rowKey={(r: InterfaceInfo) => r.ifindex}
dataSource={interfaces}
columns={interfaceColumns}
pagination={{ pageSize: 8 }}
size="small"
loading={interfacesQuery.isFetching || interfacesQuery.isLoading}
/>
</div>
</Col>
<Col span={10}>
<div style={{ background: '#fff', padding: 12, borderRadius: 6, minHeight: 220 }}>
<Title level={5} style={{ marginBottom: 12 }}>
Bridges ({bridges.length})
</Title>
<Table
rowKey={(r: BridgeInfo) => String(r.ifindex)}
dataSource={bridges}
columns={bridgeColumns}
pagination={{ pageSize: 6 }}
size="small"
loading={bridgesQuery.isFetching || bridgesQuery.isLoading}
/>
</div>
</Col>
</Row>
{/* Create Bridge Modal */}
<Modal
title="Create Bridge"
open={bridgeModalVisible}
onCancel={() => {
setBridgeModalVisible(false);
bridgeForm.resetFields();
}}
okButtonProps={{ loading: isCreating }}
onOk={() => bridgeForm.submit()}
>
<Form form={bridgeForm} layout="vertical" onFinish={handleCreateBridge}>
<Form.Item name="name" label="Bridge name" rules={[{ required: true }]}>
<Input placeholder="e.g. br0" />
</Form.Item>
{/* Select field populated from interfaces endpoint */}
<Form.Item name="interfaces" label="Interfaces (select one or more)">
<Select
mode="multiple"
placeholder={interfaces.length ? 'Select interfaces...' : 'No interfaces available'}
options={interfaceOptions}
showSearch
allowClear
loading={interfacesQuery.isFetching || interfacesQuery.isLoading}
disabled={interfacesQuery.isFetching || interfacesQuery.isLoading || interfaceOptions.length === 0}
/>
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@@ -1,10 +1,31 @@
import { Button, Typography } from 'antd';
import { useEffect } from 'react';
import { fetchFullState } from '../api/apiClient';
import { useNetwork } from '../hooks/useNetwork';
export default function Home() {
const { fullStateQuery } = useNetwork();
const { data, isLoading, isError, error } = fullStateQuery;
if (isLoading) return <div>Loading full state…</div>;
if (isError) return <div>Error: {(error as Error)?.message}</div>;
useEffect(() => {
fetchFullState().catch(() => {});
}, []);
return (
<>
<Typography>🚀 Hello MUI + React</Typography>
<Button color="primary">Klick mich!</Button>
</>
<div>
<h3>Full State</h3>
<div>
<strong>Interfaces:</strong> {data?.interfaces.length ?? 0}
</div>
<div>
<strong>Routes:</strong> {data?.routes.length ?? 0}
</div>
<div>
<strong>Bridges:</strong> {data?.bridges.length ?? 0}
</div>
<pre style={{ whiteSpace: 'pre-wrap' }}>{JSON.stringify(data, null, 2)}</pre>
</div>
);
}

5
frontend/src/routes.ts Normal file
View File

@@ -0,0 +1,5 @@
export const PATHS = {
ROOT: '/',
HOME: '/home',
NETWORK: '/network',
};

View File

@@ -1,52 +1,98 @@
@import "theme.less";
html, body, #root {
/* root */
html,
body,
#root {
height: 100%;
min-width: 100%;
margin: 0;
padding: 0;
}
/* base page colors */
body {
background-color: @body-background;
color: @text-color;
-webkit-font-smoothing: antialiased;
}
.ant-layout {
/* Top-level Ant layout becomes a row flex container:
Sidebar on the left, app-main on the right. */
.ant-layout.app-root {
display: flex;
flex-direction: row;
/* sidebar + main column */
min-height: 100vh;
/* fill viewport */
height: 100vh;
background: @body-background;
padding: 0;
overflow: hidden;
/* prevent body scrolling — content handles scrolling */
}
/* Sidebar: keep standard sider width, full height of the layout, scroll internally if needed */
.ant-layout-sider {
padding-top: 64px;
flex: 0 0 auto;
height: 100%;
min-height: 0;
/* allow internal overflow to work */
background: @layout-background;
overflow: auto;
/* if sidebar items exceed height, they scroll inside sider */
position: relative;
li span {
font-size: @font-size-slider;
}
}
/* Inner layout (app-main) is a column flex that occupies the remaining width */
.ant-layout.app-main {
display: flex;
flex-direction: column;
flex: 1 1 auto;
/* grow to fill remaining area */
min-height: 0;
/* critical so children can scroll */
background: transparent;
}
/* Header: not part of content scroll */
.ant-layout-header {
flex: 0 0 64px;
height: 64px;
line-height: 64px;
background: @layout-header-background;
color: @text-color;
font-size: @font-size-header;
position: sticky;
top: 0px;
left: 0px;
overflow: auto;
position: relative;
overflow: hidden;
}
/* Content area becomes the only scrollable pane */
.ant-layout-content {
background: @component-background;
min-height: calc(100vh - 64px - 70px); // header + footer height
}
.ant-layout-sider{
li span{ font-size: @font-size-slider;}
height: calc(100vh - 64px - 70px);
background: @component-background;
top: 64px;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
position: sticky;
/* this makes the main content scrollable */
background: @component-background;
padding: 24px;
}
/* Footer: fixed at bottom of app-main */
.ant-layout-footer {
background: @component-background;
flex: 0 0 10px;
height: 10px;
line-height: 10px;
background: @layout-background;
color: @text-color-secondary;
text-align: center;
width: auto;
border-top: 1px solid fade(@text-color-secondary, 90%);
}
/* Menu visuals */
.ant-menu-dark {
background: @menu-dark-bg;
color: @text-color;
@@ -54,4 +100,4 @@ body {
.ant-menu-dark .ant-menu-item-selected {
background-color: darken(@primary-color, 10%);
}
}

View File

@@ -1,10 +1,11 @@
// Dark theme colors
@primary-color: #1DA57A;
@text-color: #ffffff;
@text-color-secondary: #bfbfbf;
@text-color-secondary: #c9c9c9;
@heading-color: #ffffff;
@body-background: #141414;
@component-background: #1f1f1f;
@component-background: #dadada;
@layout-background: #1f1f1f;
@border-color-base: #434343;
@border-radius-base: 8px;
@font-size-base: 14px;

View File

@@ -0,0 +1,58 @@
export interface InterfaceAddress {
family: "ipv4" | "ipv6";
address: string;
prefixlen: number;
}
export interface InterfaceInfo {
ifindex: number;
name: string;
state: string;
mac?: string | null;
mtu: number;
flags: string[];
addresses: InterfaceAddress[];
}
export interface RouteInfo {
dst?: string | null;
gateway?: string | null;
prefsrc?: string | null;
oif?: number | null;
ifname?: string | null;
table: number;
proto?: number | null;
scope?: number | null;
type?: number | null;
}
export interface BridgeInterfaceInfo {
ifindex: number;
ifname: string;
state?: string | null;
mtu?: number | null;
}
export interface BridgeInfo {
ifindex: number;
ifname: string;
state?: string | null;
mtu?: number | null;
stp_state?: number | null;
members: BridgeInterfaceInfo[];
}
export interface BridgeCreateRequest {
name: string;
interfaces: string[];
}
export interface BridgeRemoveRequest {
name: string;
}
// full-state response shape
export interface FullState {
interfaces: InterfaceInfo[];
routes: RouteInfo[];
bridges: BridgeInfo[];
}