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": {
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@tanstack/react-query": "^5.90.12",
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"antd": "^6.0.0",
|
||||
"axios": "^1.13.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-router-dom": "^7.9.4"
|
||||
|
||||
@@ -12,18 +12,16 @@ export default function App() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<AppHeader />
|
||||
<Layout>
|
||||
<Layout className="app-root">
|
||||
<Sidebar collapsed={collapsed} onCollapse={setCollapsed} />
|
||||
<Layout style={{ padding: '24px' }}>
|
||||
<Content style={{ minHeight: 'calc(100vh - 64px)' }}>
|
||||
<Layout className="app-main">
|
||||
<AppHeader />
|
||||
<Content>
|
||||
<Outlet />
|
||||
</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</Layout>
|
||||
<AppFooter />
|
||||
</Layout>
|
||||
</Layout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
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 { 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(
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ConfigProvider theme={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<AppRouter />
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</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() {
|
||||
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
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";
|
||||
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
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