mirror of
https://github.com/taylanbakircioglu/haproxy-openmanager.git
synced 2026-10-30 07:35:56 +00:00
5f995d9d58
The page and its policy are two halves of one feature: what gets captured and how long it is kept lives in Settings -> Request Log, and the log itself is the page. Sitting between Config Versions and Clusters put the two at opposite ends of the menu. Now directly above Settings. Menu placement only. The route, the page, the permission gate and every backend behaviour are unchanged.
550 lines
19 KiB
JavaScript
550 lines
19 KiB
JavaScript
import React from 'react';
|
|
import { BrowserRouter as Router, Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom';
|
|
import { Layout, Menu, theme, Spin, Button, Dropdown, Avatar, Typography, ConfigProvider, Tooltip } from 'antd';
|
|
import {
|
|
DashboardOutlined,
|
|
SettingOutlined,
|
|
CloudServerOutlined,
|
|
DeploymentUnitOutlined,
|
|
FileTextOutlined,
|
|
GlobalOutlined,
|
|
SafetyCertificateOutlined,
|
|
SecurityScanOutlined,
|
|
UserOutlined,
|
|
LogoutOutlined,
|
|
DownOutlined,
|
|
MenuFoldOutlined,
|
|
MenuUnfoldOutlined,
|
|
PlayCircleOutlined,
|
|
CloudUploadOutlined,
|
|
ApiOutlined,
|
|
SearchOutlined,
|
|
ClusterOutlined,
|
|
BulbOutlined,
|
|
BulbFilled,
|
|
PlusOutlined,
|
|
ThunderboltOutlined,
|
|
FileSearchOutlined
|
|
} from '@ant-design/icons';
|
|
|
|
import Dashboard from './components/DashboardV2';
|
|
import BackendServers from './components/BackendServers';
|
|
import { FrontendManagement } from './components/FrontendManagement';
|
|
// Statistics component removed - will be redesigned later
|
|
import { Settings } from './components/Settings';
|
|
import { SSLManagement } from './components/SSLManagement';
|
|
import { WAFManagement } from './components/WAFManagement';
|
|
import ApplyManagement from './components/ApplyManagement';
|
|
import BulkVersionHistory from './components/BulkVersionHistory';
|
|
import BulkConfigImport from './components/BulkConfigImport';
|
|
import UserManagement from './components/UserManagement';
|
|
import Security from './components/Security';
|
|
import AgentManagement from './components/AgentManagement';
|
|
import PoolManagement from './components/PoolManagement';
|
|
import Login from './components/Login';
|
|
import ClusterSelector from './components/ClusterSelector';
|
|
import ClusterManagement from './components/ClusterManagement';
|
|
import VIPManagement from './components/VIPManagement';
|
|
import Configuration from './components/Configuration';
|
|
import APIDocumentation from './components/APIDocumentation';
|
|
import IPInventory from './components/IPInventory';
|
|
import SiteWizard from './components/SiteWizard';
|
|
import SiteDrafts from './components/SiteDrafts';
|
|
import RequestLog from './components/RequestLog'; // v1.11.0 — request/response log
|
|
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
|
import { ClusterProvider } from './contexts/ClusterContext';
|
|
import { ThemeProvider, useTheme } from './contexts/ThemeContext';
|
|
import { ProgressProvider } from './contexts/ProgressContext';
|
|
import GlobalProgress from './components/GlobalProgress';
|
|
|
|
import './App.css';
|
|
|
|
const { Header, Sider, Content } = Layout;
|
|
const { Text } = Typography;
|
|
|
|
// v1.3.0 - Bulk Import Change Detection + Multi-Select Delete
|
|
|
|
const menuItems = [
|
|
{
|
|
key: '/',
|
|
icon: <DashboardOutlined />,
|
|
label: <Link to="/">Dashboard</Link>,
|
|
},
|
|
// R18: rebrand to "Sites" / "New Site (Wizard)".
|
|
//
|
|
// Rationale (user feedback after R17):
|
|
// - "Quick Setup" was ambiguous — it could mean "set up the cluster",
|
|
// "set up an account", "set up TLS"... not specific enough.
|
|
// - "New Site (Wizard)" matches what dominant ingress tools call
|
|
// this exact concept: Cloudflare ("Add a site"), nginx-proxy-
|
|
// manager ("Proxy Hosts"), Plesk/cPanel ("Add domain / new site"),
|
|
// Caddy admin UIs ("Sites"). It is also self-explanatory: the
|
|
// operator publishes a *site* (frontend + backend + SSL).
|
|
// - Group label "Sites" is entity-oriented (matches Frontends,
|
|
// Backend Servers, SSL Certificates pattern in this very menu).
|
|
//
|
|
// Backward-compat:
|
|
// - Legacy R17 routes /quick-setup{,/drafts} kept as aliases.
|
|
// - Pre-R17 routes /proxied-hosts/{new,drafts} also kept.
|
|
// - All three resolve to the SAME components — old bookmarks /
|
|
// screenshots / issue links keep working.
|
|
{
|
|
key: 'sites-group',
|
|
icon: <GlobalOutlined />,
|
|
label: 'Sites',
|
|
children: [
|
|
{
|
|
key: '/sites/new',
|
|
icon: <ThunderboltOutlined style={{ color: '#1677ff' }} />,
|
|
label: (
|
|
<Tooltip placement="right" title="New Site (Wizard) — publish a site (frontend + backend + SSL) in one guided flow">
|
|
<Link to="/sites/new">New Site (Wizard)</Link>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
key: '/sites/drafts',
|
|
icon: <FileTextOutlined />,
|
|
label: <Link to="/sites/drafts">Site Drafts</Link>,
|
|
},
|
|
],
|
|
},
|
|
// R18b round 8: New Site (Wizard) is now a top-level group, so
|
|
// the Frontends entry no longer needs a single "All Frontends"
|
|
// child under a collapsible group. Flattening to a top-level
|
|
// link removes a click for every operator and matches the rest
|
|
// of the sidebar (Backends, SSL Certificates, Apply Changes...
|
|
// are all top-level).
|
|
{
|
|
key: '/frontends',
|
|
icon: <GlobalOutlined />,
|
|
label: <Link to="/frontends">Frontends</Link>,
|
|
},
|
|
{
|
|
key: '/backends',
|
|
icon: <CloudServerOutlined />,
|
|
label: <Link to="/backends">Backend Servers</Link>,
|
|
},
|
|
{
|
|
key: '/ssl-certificates',
|
|
icon: <SafetyCertificateOutlined />,
|
|
label: <Link to="/ssl-certificates">SSL Certificates</Link>,
|
|
},
|
|
{
|
|
key: '/waf',
|
|
icon: <SecurityScanOutlined />,
|
|
label: <Link to="/waf">WAF Management</Link>,
|
|
},
|
|
{
|
|
key: '/bulk-config-import',
|
|
icon: <CloudUploadOutlined />,
|
|
label: <Link to="/bulk-config-import">Config Import</Link>,
|
|
},
|
|
{
|
|
key: '/apply-management',
|
|
icon: <PlayCircleOutlined />,
|
|
label: <Link to="/apply-management">Apply Changes</Link>,
|
|
},
|
|
{
|
|
key: '/agents',
|
|
icon: <CloudServerOutlined />,
|
|
label: <Link to="/agents">Agents</Link>,
|
|
},
|
|
{
|
|
key: '/configuration',
|
|
icon: <FileTextOutlined />,
|
|
label: <Link to="/configuration">Config Versions</Link>,
|
|
},
|
|
{
|
|
key: '/clusters',
|
|
icon: <CloudServerOutlined />,
|
|
label: <Link to="/clusters">Clusters</Link>,
|
|
},
|
|
{
|
|
key: '/ha-vip',
|
|
icon: <DeploymentUnitOutlined />,
|
|
label: <Link to="/ha-vip">HA / VIP</Link>,
|
|
},
|
|
{
|
|
key: '/pools',
|
|
icon: <GlobalOutlined />,
|
|
label: <Link to="/pools">Agent Pools</Link>,
|
|
},
|
|
{
|
|
key: '/ip-inventory',
|
|
icon: <SearchOutlined />,
|
|
label: <Link to="/ip-inventory">IP Inventory</Link>,
|
|
},
|
|
{
|
|
key: '/users',
|
|
icon: <UserOutlined />,
|
|
label: <Link to="/users">Users</Link>,
|
|
},
|
|
{
|
|
key: '/security',
|
|
icon: <SecurityScanOutlined />,
|
|
label: <Link to="/security">Security</Link>,
|
|
},
|
|
// v1.11.0 — sits directly above Settings on purpose: the Request Log page
|
|
// and its retention/capture policy (Settings -> Request Log) are the two
|
|
// halves of one feature, so they are adjacent in the sidebar.
|
|
{
|
|
key: '/request-log',
|
|
icon: <FileSearchOutlined />,
|
|
label: (
|
|
<Tooltip placement="right" title="Request Log — every API call in, and every HTTP call this backend made out (ACME, DNS, agents)">
|
|
<Link to="/request-log">Request Log</Link>
|
|
</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
key: '/settings',
|
|
icon: <SettingOutlined />,
|
|
label: <Link to="/settings">Settings</Link>,
|
|
},
|
|
{
|
|
key: '/api-docs',
|
|
icon: <ApiOutlined />,
|
|
label: <Link to="/api-docs">APIs</Link>,
|
|
},
|
|
];
|
|
|
|
function AppContent() {
|
|
const {
|
|
token: { colorBgContainer, colorTextSecondary, colorBgLayout },
|
|
} = theme.useToken();
|
|
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const [selectedKey, setSelectedKey] = React.useState(location.pathname);
|
|
const [collapsed, setCollapsed] = React.useState(false);
|
|
const { user, logout, isAuthenticated, loading, getUserRoleNames } = useAuth();
|
|
const { isDarkMode, toggleTheme } = useTheme();
|
|
const [appVersion, setAppVersion] = React.useState('');
|
|
|
|
React.useEffect(() => {
|
|
// R18 audit fix: legacy aliases (/proxied-hosts/* and /quick-setup/*)
|
|
// resolve to the same components as the canonical /sites/* paths,
|
|
// but the menu items only carry the canonical keys. Without this
|
|
// normalization, a user landing on a bookmarked legacy URL sees no
|
|
// sidebar highlight, which feels like the route is broken.
|
|
const path = location.pathname;
|
|
let key = path;
|
|
if (path === '/proxied-hosts/new' || path === '/quick-setup') {
|
|
key = '/sites/new';
|
|
} else if (path === '/proxied-hosts/drafts' || path === '/quick-setup/drafts') {
|
|
key = '/sites/drafts';
|
|
}
|
|
setSelectedKey(key);
|
|
}, [location.pathname]);
|
|
|
|
React.useEffect(() => {
|
|
fetch('/api/version')
|
|
.then(res => res.ok ? res.json() : null)
|
|
.then(data => { if (data?.version) setAppVersion(data.version); })
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
const handleLogout = async () => {
|
|
await logout();
|
|
};
|
|
|
|
const userMenuItems = [
|
|
{
|
|
key: 'profile',
|
|
label: (
|
|
<div style={{ padding: '8px 0' }}>
|
|
<div style={{ fontWeight: 500 }}>{user?.username}</div>
|
|
<div style={{ fontSize: '12px', color: colorTextSecondary }}>{getUserRoleNames()}</div>
|
|
</div>
|
|
),
|
|
disabled: true,
|
|
},
|
|
{
|
|
type: 'divider',
|
|
},
|
|
{
|
|
key: 'logout',
|
|
label: 'Logout',
|
|
icon: <LogoutOutlined />,
|
|
onClick: handleLogout,
|
|
},
|
|
];
|
|
|
|
if (loading) {
|
|
return (
|
|
<div style={{
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
height: '100vh',
|
|
flexDirection: 'column',
|
|
gap: '16px'
|
|
}}>
|
|
<Spin size="large" />
|
|
<Text type="secondary">Loading...</Text>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
return <Login />;
|
|
}
|
|
|
|
return (
|
|
<Layout style={{ minHeight: '100vh' }}>
|
|
<Sider
|
|
trigger={null}
|
|
collapsible
|
|
collapsed={collapsed}
|
|
width={240}
|
|
breakpoint="lg"
|
|
collapsedWidth="0"
|
|
onBreakpoint={(broken) => {
|
|
setCollapsed(broken);
|
|
}}
|
|
style={isDarkMode ? { background: '#141414' } : undefined}
|
|
>
|
|
<div style={{
|
|
height: 64,
|
|
margin: 16,
|
|
marginBottom: 24,
|
|
background: 'rgba(255, 255, 255, 0.15)',
|
|
borderRadius: 8,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
color: 'white',
|
|
fontWeight: 'bold',
|
|
fontSize: collapsed ? '14px' : '16px',
|
|
transition: 'all 0.2s',
|
|
overflow: 'hidden'
|
|
}}>
|
|
{collapsed ? (
|
|
<ClusterOutlined style={{
|
|
fontSize: '22px',
|
|
color: '#1890ff',
|
|
textShadow: '0 0 10px rgba(24, 144, 255, 0.5)'
|
|
}} />
|
|
) : (
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
fontSize: '14px'
|
|
}}>
|
|
<ClusterOutlined style={{
|
|
fontSize: '22px',
|
|
color: '#1890ff',
|
|
textShadow: '0 0 10px rgba(24, 144, 255, 0.5)'
|
|
}} />
|
|
<span>HAProxy OpenManager</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Menu
|
|
theme="dark"
|
|
mode="inline"
|
|
selectedKeys={[selectedKey]}
|
|
// R18: auto-open the relevant submenu based on the current
|
|
// route. The Sites group expands on the canonical /sites/*
|
|
// route AND on every legacy alias (/quick-setup/*, /proxied-
|
|
// hosts/*) — so a user landing on a bookmarked old URL still
|
|
// sees the menu in the right state.
|
|
defaultOpenKeys={[
|
|
...(selectedKey.startsWith('/sites') ||
|
|
selectedKey.startsWith('/quick-setup') ||
|
|
selectedKey.startsWith('/proxied-hosts')
|
|
? ['sites-group']
|
|
: []),
|
|
// R18b round 8: frontends-group flattened to a top-level
|
|
// link, so no defaultOpenKeys entry is needed for it.
|
|
]}
|
|
items={menuItems}
|
|
style={isDarkMode ? { background: '#141414' } : undefined}
|
|
/>
|
|
{!collapsed && appVersion && (
|
|
<div style={{
|
|
padding: '8px 24px 12px',
|
|
color: 'rgba(255,255,255,0.3)',
|
|
fontSize: 11,
|
|
textAlign: 'center',
|
|
borderTop: '1px solid rgba(255,255,255,0.06)'
|
|
}}>
|
|
v{appVersion}
|
|
</div>
|
|
)}
|
|
</Sider>
|
|
<Layout style={{ background: colorBgLayout }}>
|
|
<Header
|
|
style={{
|
|
padding: 0,
|
|
background: colorBgContainer,
|
|
boxShadow: '0 1px 4px rgba(0,21,41,.08)',
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center'
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
|
<Button
|
|
type="text"
|
|
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
|
|
onClick={() => setCollapsed(!collapsed)}
|
|
style={{
|
|
fontSize: '16px',
|
|
width: 64,
|
|
height: 64,
|
|
}}
|
|
/>
|
|
<div className="header-title" style={{
|
|
fontSize: '18px',
|
|
fontWeight: 'bold',
|
|
color: '#1890ff',
|
|
marginLeft: '8px',
|
|
whiteSpace: 'nowrap'
|
|
}}>
|
|
HAProxy OpenManager
|
|
</div>
|
|
</div>
|
|
<div style={{ flex: 1, display: 'flex', justifyContent: 'center', padding: '0 16px', minWidth: 0 }}>
|
|
{location.pathname === '/ip-inventory' ? (
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#8c8c8c' }}>
|
|
<GlobalOutlined />
|
|
<span style={{ fontWeight: 500 }}>All Clusters</span>
|
|
</div>
|
|
) : (
|
|
<ClusterSelector />
|
|
)}
|
|
</div>
|
|
<div style={{ padding: '0 24px', display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<Tooltip title={isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'}>
|
|
<Button
|
|
type="text"
|
|
icon={isDarkMode ? <BulbOutlined /> : <BulbFilled />}
|
|
onClick={toggleTheme}
|
|
style={{ fontSize: '18px' }}
|
|
/>
|
|
</Tooltip>
|
|
<Dropdown
|
|
menu={{ items: userMenuItems }}
|
|
trigger={['click']}
|
|
placement="bottomRight"
|
|
>
|
|
<Button
|
|
type="text"
|
|
style={{
|
|
height: '40px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '8px',
|
|
fontWeight: 500
|
|
}}
|
|
>
|
|
<Avatar size="small" icon={<UserOutlined />} />
|
|
<span>{user?.username}</span>
|
|
<DownOutlined style={{ fontSize: '10px' }} />
|
|
</Button>
|
|
</Dropdown>
|
|
</div>
|
|
</Header>
|
|
<Content
|
|
style={{
|
|
margin: '24px 16px',
|
|
padding: 24,
|
|
minHeight: 280,
|
|
background: colorBgContainer,
|
|
}}
|
|
>
|
|
<Routes>
|
|
<Route path="/" element={<Dashboard />} />
|
|
<Route path="/frontends" element={<FrontendManagement />} />
|
|
{/* R18: canonical "Sites" routes — the New Site (Wizard)
|
|
is the primary entry point under the top-level Sites
|
|
group. Two layers of legacy aliases follow so neither
|
|
v1.5.0 (proxied-hosts) nor R17 (quick-setup) deep links
|
|
break. All three resolve to the same components. */}
|
|
<Route path="/sites/new" element={<SiteWizard />} />
|
|
<Route path="/sites/drafts" element={<SiteDrafts />} />
|
|
{/* R17 legacy aliases */}
|
|
<Route path="/quick-setup" element={<SiteWizard />} />
|
|
<Route path="/quick-setup/drafts" element={<SiteDrafts />} />
|
|
{/* v1.5.0 legacy aliases */}
|
|
<Route path="/proxied-hosts/new" element={<SiteWizard />} />
|
|
<Route path="/proxied-hosts/drafts" element={<SiteDrafts />} />
|
|
<Route path="/backends" element={<BackendServers />} />
|
|
<Route path="/ssl-certificates" element={<SSLManagement />} />
|
|
<Route path="/waf" element={<WAFManagement />} />
|
|
<Route path="/bulk-config-import" element={<BulkConfigImport />} />
|
|
<Route path="/apply-management" element={<ApplyManagement />} />
|
|
<Route path="/version-history" element={<BulkVersionHistory />} />
|
|
{/* Statistics route temporarily removed - will be redesigned */}
|
|
<Route path="/users" element={<UserManagement />} />
|
|
<Route path="/security" element={<Security />} />
|
|
<Route path="/agents" element={<AgentManagement />} />
|
|
<Route path="/configuration" element={<Configuration />} />
|
|
{/* v1.11.0 — the page gates itself on requestlog.read; the route
|
|
is unconditional like every other route in this app. */}
|
|
<Route path="/request-log" element={<RequestLog />} />
|
|
<Route path="/api-docs" element={<APIDocumentation />} />
|
|
<Route path="/pools" element={<PoolManagement />} />
|
|
<Route path="/settings" element={<Settings />} />
|
|
<Route path="/clusters" element={<ClusterManagement />} />
|
|
<Route path="/ha-vip" element={<VIPManagement />} />
|
|
<Route path="/ip-inventory" element={<IPInventory />} />
|
|
</Routes>
|
|
</Content>
|
|
</Layout>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
function ThemedApp() {
|
|
const { isDarkMode } = useTheme();
|
|
const antdTheme = {
|
|
algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
|
};
|
|
|
|
// Ant Design 5: the STATIC message/notification/Modal.confirm APIs render into their own
|
|
// detached root, so they do not see this ConfigProvider and always fall back to the light
|
|
// algorithm — a confirm dialog came up white while the app was in dark mode. `holderRender`
|
|
// wraps that detached root in the same ConfigProvider, which fixes every static call in the
|
|
// app at once (12 components use Modal.confirm) instead of migrating each one to
|
|
// App.useApp(). In an effect rather than during render: ConfigProvider.config() mutates
|
|
// antd module state, and effects still run long before a user can click anything that opens
|
|
// a static modal. Re-registered on theme change so the toggle takes effect immediately.
|
|
React.useEffect(() => {
|
|
ConfigProvider.config({
|
|
holderRender: (children) => (
|
|
<ConfigProvider theme={{ algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm }}>
|
|
{children}
|
|
</ConfigProvider>
|
|
),
|
|
});
|
|
}, [isDarkMode]);
|
|
|
|
return (
|
|
<ConfigProvider theme={antdTheme}>
|
|
<AuthProvider>
|
|
<ClusterProvider>
|
|
<ProgressProvider>
|
|
<Router>
|
|
<AppContent />
|
|
<GlobalProgress />
|
|
</Router>
|
|
</ProgressProvider>
|
|
</ClusterProvider>
|
|
</AuthProvider>
|
|
</ConfigProvider>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
return (
|
|
<ThemeProvider>
|
|
<ThemedApp />
|
|
</ThemeProvider>
|
|
);
|
|
}
|
|
|
|
export default App; |