restructure frontend and add network settings
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m41s
All checks were successful
Build and Deploy MITM Webserver / build (push) Successful in 1m41s
This commit is contained in:
1371
frontend/package-lock.json
generated
1371
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons": "^6.1.0",
|
"@ant-design/icons": "^6.1.0",
|
||||||
|
"@tanstack/react-query": "^5.90.12",
|
||||||
|
"@tanstack/react-query-devtools": "^5.91.1",
|
||||||
"antd": "^6.0.0",
|
"antd": "^6.0.0",
|
||||||
|
"axios": "^1.13.2",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-router-dom": "^7.9.4"
|
"react-router-dom": "^7.9.4"
|
||||||
|
|||||||
@@ -12,18 +12,16 @@ export default function App() {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout className="app-root">
|
||||||
<AppHeader />
|
<Sidebar collapsed={collapsed} onCollapse={setCollapsed} />
|
||||||
<Layout>
|
<Layout className="app-main">
|
||||||
<Sidebar collapsed={collapsed} onCollapse={setCollapsed} />
|
<AppHeader />
|
||||||
<Layout style={{ padding: '24px' }}>
|
<Content>
|
||||||
<Content style={{ minHeight: 'calc(100vh - 64px)' }}>
|
<Outlet />
|
||||||
<Outlet />
|
</Content>
|
||||||
</Content>
|
<AppFooter />
|
||||||
</Layout>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
<AppFooter />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
// src/components/Sidebar.tsx
|
// 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 { Layout, Menu } from 'antd';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { PATHS } from '../routes';
|
||||||
import '../theme/layout.less';
|
import '../theme/layout.less';
|
||||||
|
|
||||||
const { Sider } = Layout;
|
const { Sider } = Layout;
|
||||||
|
|
||||||
const menuItems = [
|
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: '/about', icon: <InfoCircleOutlined />, label: 'About' },
|
||||||
{ key: '/settings', icon: <SettingOutlined />, label: 'Settings' },
|
{ key: '/settings', icon: <SettingOutlined />, label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|||||||
55
frontend/src/api/apiClient.ts
Normal file
55
frontend/src/api/apiClient.ts
Normal 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;
|
||||||
|
};
|
||||||
15
frontend/src/api/queryClient.ts
Normal file
15
frontend/src/api/queryClient.ts
Normal 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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
35
frontend/src/appRouter.tsx
Normal file
35
frontend/src/appRouter.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
frontend/src/hooks/useNetwork.ts
Normal file
227
frontend/src/hooks/useNetwork.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
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 './index.less';
|
||||||
import './theme/theme.less';
|
import './theme/theme.less';
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
createRoot(document.getElementById('root')!).render(
|
||||||
if (rootElement) {
|
<StrictMode>
|
||||||
createRoot(rootElement).render(
|
<QueryClientProvider client={queryClient}>
|
||||||
<StrictMode>
|
<BrowserRouter>
|
||||||
<ConfigProvider theme={{}}>
|
<AppRouter />
|
||||||
<BrowserRouter>
|
</BrowserRouter>
|
||||||
<App />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</BrowserRouter>
|
</QueryClientProvider>
|
||||||
</ConfigProvider>
|
</StrictMode>,
|
||||||
</StrictMode>,
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
234
frontend/src/pages/Bridges.tsx
Normal file
234
frontend/src/pages/Bridges.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
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 (
|
return (
|
||||||
<>
|
<div>
|
||||||
<Typography>🚀 Hello MUI + React</Typography>
|
<h3>Full State</h3>
|
||||||
<Button color="primary">Klick mich!</Button>
|
<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
5
frontend/src/routes.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export const PATHS = {
|
||||||
|
ROOT: '/',
|
||||||
|
HOME: '/home',
|
||||||
|
NETWORK: '/network',
|
||||||
|
};
|
||||||
@@ -1,52 +1,98 @@
|
|||||||
@import "theme.less";
|
@import "theme.less";
|
||||||
|
|
||||||
html, body, #root {
|
/* root */
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
min-width: 100%;
|
min-width: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* base page colors */
|
||||||
body {
|
body {
|
||||||
background-color: @body-background;
|
background-color: @body-background;
|
||||||
color: @text-color;
|
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;
|
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 {
|
.ant-layout-header {
|
||||||
|
flex: 0 0 64px;
|
||||||
|
height: 64px;
|
||||||
|
line-height: 64px;
|
||||||
background: @layout-header-background;
|
background: @layout-header-background;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
font-size: @font-size-header;
|
font-size: @font-size-header;
|
||||||
position: sticky;
|
position: relative;
|
||||||
top: 0px;
|
overflow: hidden;
|
||||||
left: 0px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Content area becomes the only scrollable pane */
|
||||||
.ant-layout-content {
|
.ant-layout-content {
|
||||||
background: @component-background;
|
flex: 1 1 auto;
|
||||||
min-height: calc(100vh - 64px - 70px); // header + footer height
|
min-height: 0;
|
||||||
}
|
|
||||||
|
|
||||||
.ant-layout-sider{
|
|
||||||
li span{ font-size: @font-size-slider;}
|
|
||||||
height: calc(100vh - 64px - 70px);
|
|
||||||
background: @component-background;
|
|
||||||
top: 64px;
|
|
||||||
overflow: auto;
|
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 {
|
.ant-layout-footer {
|
||||||
background: @component-background;
|
flex: 0 0 10px;
|
||||||
|
height: 10px;
|
||||||
|
line-height: 10px;
|
||||||
|
background: @layout-background;
|
||||||
color: @text-color-secondary;
|
color: @text-color-secondary;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
width: auto;
|
border-top: 1px solid fade(@text-color-secondary, 90%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Menu visuals */
|
||||||
.ant-menu-dark {
|
.ant-menu-dark {
|
||||||
background: @menu-dark-bg;
|
background: @menu-dark-bg;
|
||||||
color: @text-color;
|
color: @text-color;
|
||||||
@@ -54,4 +100,4 @@ body {
|
|||||||
|
|
||||||
.ant-menu-dark .ant-menu-item-selected {
|
.ant-menu-dark .ant-menu-item-selected {
|
||||||
background-color: darken(@primary-color, 10%);
|
background-color: darken(@primary-color, 10%);
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
// Dark theme colors
|
// Dark theme colors
|
||||||
@primary-color: #1DA57A;
|
@primary-color: #1DA57A;
|
||||||
@text-color: #ffffff;
|
@text-color: #ffffff;
|
||||||
@text-color-secondary: #bfbfbf;
|
@text-color-secondary: #c9c9c9;
|
||||||
@heading-color: #ffffff;
|
@heading-color: #ffffff;
|
||||||
@body-background: #141414;
|
@body-background: #141414;
|
||||||
@component-background: #1f1f1f;
|
@component-background: #dadada;
|
||||||
|
@layout-background: #1f1f1f;
|
||||||
@border-color-base: #434343;
|
@border-color-base: #434343;
|
||||||
@border-radius-base: 8px;
|
@border-radius-base: 8px;
|
||||||
@font-size-base: 14px;
|
@font-size-base: 14px;
|
||||||
|
|||||||
58
frontend/src/types/network.ts
Normal file
58
frontend/src/types/network.ts
Normal 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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user