diff --git a/webui/IMPLEMENTATION.md b/webui/IMPLEMENTATION.md index 6bdf4ae4..adb177bd 100644 --- a/webui/IMPLEMENTATION.md +++ b/webui/IMPLEMENTATION.md @@ -1,81 +1,81 @@ -# NGINX Declarative API - Web UI Implementation Summary +# NGINX Declarative API - Web UI Implementation Details ## Overview -A modern, full-featured React 19 TypeScript web interface has been created for the NGINX Declarative API v5.5. The UI provides a user-friendly way to manage NGINX configurations through the declarative API. +A React 19 TypeScript single-page application for creating and submitting NGINX Declarative API v5.6 configurations. The UI renders a structured form and submits the resulting JSON to the backend API. -## ๐ŸŽฏ Features Implemented +## Features Implemented -### Authentication +### Configuration Form -- โœ… JWT-based authentication -- โœ… Token stored in localStorage (with security disclaimer) -- โœ… Protected routes -- โœ… Automatic token injection in API requests -- โœ… Auto-logout on 401 errors - -### UI Pages - -- โœ… **Login Page** - JWT token input with validation -- โœ… **Dashboard** - Overview of configurations with status monitoring -- โœ… **Create Configuration** - JSON editor for creating new configs -- โœ… **Configuration Management** - View, edit, delete operations +- Output section โ€” target NGINX Instance Manager or NGINX One Console, with license (JWT token upload, boolean enforce-initial-report toggle) +- HTTP section โ€” policies, TLS certificates, and log profiles (moved to declaration body in v5.6), plus profiles (rate limiting, auth, authz, caching, maps, log), servers, and upstreams +- Sticky sidebar navigation with IntersectionObserver-based active-section highlighting +- Layer 4 section โ€” TCP/UDP servers and upstreams +- API Gateway editor โ€” per-location OpenAPI schema integration (URL / file upload / base64) ### API Integration -All v5.5 endpoints are integrated: +All v5.6 endpoints are integrated: -- โœ… POST /v5.5/config - Create configuration -- โœ… GET /v5.5/config/{configUid} - Retrieve configuration -- โœ… PATCH /v5.5/config/{configUid} - Update configuration -- โœ… DELETE /v5.5/config/{configUid} - Delete configuration -- โœ… GET /v5.5/config/{configUid}/status - Get status -- โœ… GET /v5.5/config/{configUid}/submission/{submissionUid} - Get async submission status +- POST /v5.6/config โ€” Create configuration +- GET /v5.6/config/{configUid} โ€” Retrieve configuration +- PATCH /v5.6/config/{configUid} โ€” Update configuration +- DELETE /v5.6/config/{configUid} โ€” Delete configuration +- GET /v5.6/config/{configUid}/status โ€” Get status +- GET /v5.6/config/{configUid}/submission/{submissionUid} โ€” Get async submission status ### Testing -- โœ… Test setup with Vitest -- โœ… Component tests (LoginPage) -- โœ… Store tests (AuthStore) -- โœ… Hook tests (useConfig) -- โœ… Coverage reporting configured +- Vitest + React Testing Library +- 91 tests across 5 files +- Component tests (ConfigForm API Gateway, validation, profile dropdowns, OutputSection) +- Page-level integration tests (CreateConfigPage) +- Coverage reporting configured ### DevOps -- โœ… Docker support with multi-stage build -- โœ… NGINX reverse proxy configuration -- โœ… Integration with docker-compose -- โœ… Configurable ports -- โœ… Development and production builds +- Docker support with multi-stage build +- NGINX reverse proxy configuration +- Integration with docker-compose +- Configurable ports +- Development and production builds ## ๐Ÿ“ Project Structure ```text webui/ โ”œโ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ api/ # API client layer -โ”‚ โ”‚ โ””โ”€โ”€ config.ts # Config API methods โ”‚ โ”œโ”€โ”€ components/ # Reusable components +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.tsx # Root form (thin wrapper) +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.css โ”‚ โ”‚ โ”œโ”€โ”€ Header.tsx โ”‚ โ”‚ โ”œโ”€โ”€ Layout.tsx -โ”‚ โ”‚ โ””โ”€โ”€ ProtectedRoute.tsx -โ”‚ โ”œโ”€โ”€ hooks/ # Custom React hooks -โ”‚ โ”‚ โ””โ”€โ”€ useConfig.ts # React Query hooks for API -โ”‚ โ”œโ”€โ”€ lib/ # Libraries & utilities -โ”‚ โ”‚ โ””โ”€โ”€ axios.ts # Axios instance with interceptors +โ”‚ โ”‚ โ””โ”€โ”€ configForm/ # Form sub-modules +โ”‚ โ”‚ โ”œโ”€โ”€ types.ts # All TypeScript interfaces +โ”‚ โ”‚ โ”œโ”€โ”€ defaults.ts # Factory functions, parseConfig, toJson +โ”‚ โ”‚ โ”œโ”€โ”€ primitives.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ ApiGatewayEditor.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ LocationEditors.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ LocationsEditor.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ TlsEditor.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ ServersSection.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ UpstreamsSection.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ ProfilesSection.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ HttpSection.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ OutputSection.tsx +โ”‚ โ”‚ โ””โ”€โ”€ Layer4Section.tsx โ”‚ โ”œโ”€โ”€ pages/ # Page components -โ”‚ โ”‚ โ”œโ”€โ”€ LoginPage.tsx -โ”‚ โ”‚ โ”œโ”€โ”€ DashboardPage.tsx โ”‚ โ”‚ โ””โ”€โ”€ CreateConfigPage.tsx -โ”‚ โ”œโ”€โ”€ store/ # State management -โ”‚ โ”‚ โ””โ”€โ”€ authStore.ts # Zustand auth store โ”‚ โ”œโ”€โ”€ test/ # Test files โ”‚ โ”‚ โ”œโ”€โ”€ setup.ts -โ”‚ โ”‚ โ”œโ”€โ”€ LoginPage.test.tsx -โ”‚ โ”‚ โ”œโ”€โ”€ authStore.test.ts -โ”‚ โ”‚ โ””โ”€โ”€ useConfig.test.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.agw.test.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.agw.validation.test.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.agw.profiles.test.tsx +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.output.test.tsx +โ”‚ โ”‚ โ””โ”€โ”€ CreateConfigPage.test.tsx โ”‚ โ”œโ”€โ”€ types/ # TypeScript definitions -โ”‚ โ”‚ โ””โ”€โ”€ index.ts # API & app types +โ”‚ โ”‚ โ””โ”€โ”€ index.ts โ”‚ โ”œโ”€โ”€ App.tsx # Main app with routing โ”‚ โ”œโ”€โ”€ main.tsx # Entry point โ”‚ โ””โ”€โ”€ index.css # Global styles @@ -83,24 +83,21 @@ webui/ โ”œโ”€โ”€ nginx.conf # NGINX config for production โ”œโ”€โ”€ vite.config.ts # Vite configuration โ”œโ”€โ”€ tsconfig.json # TypeScript config -โ”œโ”€โ”€ package.json # Dependencies -โ””โ”€โ”€ README.md # Web UI documentation +โ””โ”€โ”€ package.json # Dependencies ``` ## ๐Ÿ›  Technology Stack -|Category|Technology|Purpose| +| Category | Technology | Purpose | |-|-|-| -|**Framework**|React 19|UI library| -|**Language**|TypeScript|Type safety| -|**Build Tool**|Vite|Fast dev server & build| -|**Routing**|React Router v7|Client-side routing| -|**State Management**|Zustand|Auth state| -|**Server State**|TanStack Query|API state & caching| -|**HTTP Client**|Axios|API requests| -|**UI Feedback**|React Hot Toast|Notifications| -|**Testing**|Vitest + Testing Library|Unit & component tests| -|**Code Quality**|ESLint + Prettier|Linting & formatting| +| **Framework** | React 19 | UI library | +| **Language** | TypeScript | Type safety | +| **Build Tool** | Vite | Fast dev server & build | +| **Routing** | React Router v7 | Client-side routing | +| **HTTP Client** | Axios | API requests | +| **JSON Editor** | Monaco Editor | Schema-aware JSON editing | +| **UI Feedback** | React Hot Toast | Notifications | +| **Testing** | Vitest + Testing Library | Unit & component tests | ## ๐Ÿš€ Getting Started @@ -112,7 +109,7 @@ npm install npm run dev ``` -Access at +Access at ### Running Tests @@ -132,7 +129,6 @@ npm run preview ### Docker ```bash -# From repo root cd contrib/docker-compose # Build all images including Web UI @@ -147,44 +143,20 @@ cd contrib/docker-compose **Access Points:** -- Web UI: (or custom port) -- API: (or custom port) -- DevPortal: (or custom port) - -## ๐Ÿ” Security Notes - -**Current Implementation (Development):** - -- JWT tokens stored in localStorage -- Simple Bearer token authentication -- CORS handled by backend - -**โš ๏ธ Production Recommendations:** - -1. Use HTTP-only cookies for token storage -2. Implement token refresh mechanism -3. Add CSRF protection -4. Enable rate limiting -5. Use HTTPS/TLS in production -6. Implement proper session management -7. Add input validation and sanitization -8. Consider OAuth2/OIDC for enterprise use +- Web UI: (or custom port) +- API: (or custom port) +- DevPortal: (or custom port) ## ๐Ÿ“‹ Type Definitions -The UI includes comprehensive TypeScript types based on the OpenAPI spec: +Key TypeScript interfaces (see `src/components/configForm/types.ts`): ```typescript -interface ConfigDeclaration { - output?: { - type?: 'nms' | 'nginxone'; - license?: LicenseConfig; - nms?: NMSConfig; - nginxone?: NginxOneConfig; - }; +interface ConfigData { + output?: OutputConfig; declaration?: { - http?: HttpConfig[]; - layer4?: Layer4Config[]; + http?: HttpConfig; + layer4?: Layer4Config; resolvers?: ResolverConfig[]; }; } @@ -207,45 +179,17 @@ interface ConfigDeclaration { - Smooth animations and transitions - Responsive grid layouts - Status indicators with color coding -- Monaco-style code editor aesthetics ## ๐Ÿ“Š API Request Flow ```text -User Action โ†’ React Component โ†’ React Query Hook โ†’ API Service โ†’ Axios โ†’ Backend API - โ†“ - Cache & State Update - โ†“ - UI Re-render +User Action โ†’ React Component โ†’ Fetch/Axios โ†’ Backend API + โ†“ + State Update (useState) + โ†“ + UI Re-render ``` -## ๐Ÿงช Test Coverage - -Tests implemented for: - -- โœ… Authentication store (login/logout) -- โœ… Login page rendering -- โœ… Config creation hook -- โœ… API service layer (mocked) - -Run `npm run test:coverage` for detailed coverage report. - -## ๐Ÿ”„ State Management - -**Client State (Zustand):** - -- Authentication state -- User token -- Login/logout actions - -**Server State (TanStack Query):** - -- Configuration data -- Status monitoring -- Submission tracking -- Automatic refetching -- Optimistic updates - ## ๐ŸŒ API Proxy Configuration **Development (Vite):** @@ -268,46 +212,25 @@ location /v5.5/ { ## ๐Ÿ“ฆ Docker Multi-Stage Build -1. **Build stage:** Node.js 24 Alpine - - Install dependencies - - Build production bundle - -2. **Production stage:** NGINX Alpine - - Copy built files - - Serve with NGINX - - Proxy API requests to backend - -## ๐ŸŽฏ Future Enhancements - -### Planned Features - -- [ ] Form builder for visual config creation -- [ ] Configuration templates library -- [ ] Bulk operations -- [ ] Configuration diff viewer -- [ ] Export/import configurations -- [ ] Real-time collaboration -- [ ] Audit log viewer -- [ ] Advanced search and filtering -- [ ] Configuration validation preview -- [ ] Integration with CI/CD pipelines - -### Technical Improvements - -- [ ] HTTP-only cookie authentication -- [ ] Token refresh mechanism -- [ ] WebSocket support for real-time updates -- [ ] Advanced Monaco editor integration -- [ ] Configuration schema validation -- [ ] Offline support with service workers -- [ ] Internationalization (i18n) -- [ ] Accessibility (WCAG 2.1 AA) +1. **Build stage:** Node.js 24 Alpine โ€” install dependencies, build production bundle +2. **Production stage:** NGINX Alpine โ€” copy built files, serve with NGINX, proxy API requests to backend + +## ๐Ÿงช Test Coverage + +Tests implemented for: + +- API Gateway section toggle and OpenAPI schema modes +- Field validation (required error messages) +- Profile dropdown population from HTTP-level profiles +- Live profile name propagation to API Gateway location dropdowns +- Page-level integration (submit, JSON editor round-trip, status polling) ## ๐Ÿ“š Related Documentation -- [Web UI README](../webui/README.md) - Detailed Web UI documentation +- [Web UI README](README.md) - Setup and usage guide - [USAGE-v5.5.md](../USAGE-v5.5.md) - API v5.5 usage guide - [Docker Compose README](../contrib/docker-compose/README.md) - Deployment guide +- [OpenAPI Spec](../openapi.json) - Complete API specification ## ๐Ÿค Contributing @@ -315,15 +238,9 @@ When contributing to the Web UI: 1. Follow TypeScript best practices 2. Write tests for new features -3. Use Prettier for code formatting -4. Update type definitions when API changes -5. Document new components and hooks -6. Ensure accessibility standards +3. Update type definitions when API changes +4. Document new components ## ๐Ÿ“„ License Apache License 2.0 - See [LICENSE.md](../LICENSE.md) - ---- - -Built with โค๏ธ for the NGINX Declarative API project diff --git a/webui/QUICKSTART.md b/webui/QUICKSTART.md index 3455ef46..cf5d283b 100644 --- a/webui/QUICKSTART.md +++ b/webui/QUICKSTART.md @@ -46,8 +46,8 @@ Open browser: ### Step 4: Create Your First Configuration -1. Click "Create Config" in navigation -2. Edit the JSON declaration +1. Fill in the Output section (choose NIM or NGINX One target) +2. Add HTTP or Layer 4 configuration as needed 3. Click "Create Configuration" Example minimal config: @@ -172,12 +172,10 @@ Dev server runs at with auto-reload. ## ๐ŸŽฏ Next Steps -1. โœ… Login to Web UI -2. โœ… Explore the Dashboard -3. โœ… Create a test configuration -4. ๐Ÿ“– Read the API v5.5 usage guide in `/USAGE-v5.5.md` for API details -5. ๐Ÿ“š Check [Web UI Documentation](README.md) -6. ๐Ÿงช Try the Postman collection in `/contrib/postman` +1. โœ… Create a test configuration +2. ๐Ÿ“– Read the API v5.5 usage guide in `/USAGE-v5.5.md` for API details +3. ๐Ÿ“š Check [Web UI Documentation](README.md) +4. ๐Ÿงช Try the Postman collection in `/contrib/postman` ## ๐Ÿ’ก Tips diff --git a/webui/README.md b/webui/README.md index f1bb43b1..48b96a54 100644 --- a/webui/README.md +++ b/webui/README.md @@ -4,11 +4,9 @@ Modern React TypeScript web interface for the NGINX Declarative API v5.5. ## Features -- ๐Ÿ” JWT-based authentication (stored in localStorage) -- ๐Ÿ“ Create and manage NGINX configurations +- ๏ฟฝ Create and manage NGINX configurations - ๐ŸŽจ Modern, responsive UI with dark theme - ๐Ÿ”„ Real-time status monitoring -- ๐Ÿ“Š Dashboard for configuration overview - ๐Ÿงช Automated test suite with Vitest - ๐Ÿš€ Built with React 19, TypeScript, and Vite @@ -150,25 +148,11 @@ docker run -p 8080:80 nginx-dapi-webui ## Usage -### Login - -1. Navigate to the login page -2. Enter your JWT token -3. Click "Login" - -**Note:** The JWT token is stored in localStorage for convenience. This is not recommended for production use and should be replaced with a more secure authentication method (e.g., HTTP-only cookies, secure token refresh mechanism). - -### Dashboard - -- View all configurations -- Monitor configuration status -- Quick actions (view, delete) - ### Create Configuration - Use JSON editor to create configurations - Based on v5.5 API schema -- Support for NMS and NGINX One outputs +- Support for NIM and NGINX One outputs ## API Endpoints @@ -231,13 +215,11 @@ webui/ ## Security Considerations -- JWT tokens are currently stored in localStorage - not ideal for production -- Consider implementing: - - HTTP-only cookies for token storage - - Token refresh mechanism - - CSRF protection - - Rate limiting - - Input validation and sanitization +- Enable HTTPS/TLS in production +- Configure CORS headers appropriately on the backend +- Add rate limiting to API endpoints +- Enable security headers (CSP, HSTS, X-Frame-Options) +- Validate and sanitize all user inputs ## Contributing diff --git a/webui/SUMMARY.md b/webui/SUMMARY.md index 11c55500..c5f2d22b 100644 --- a/webui/SUMMARY.md +++ b/webui/SUMMARY.md @@ -1,117 +1,79 @@ # Web UI Summary - NGINX Declarative API -## โœ… Project Complete +## Project Overview -A fully functional, production-ready React TypeScript web interface has been successfully created for the NGINX Declarative API v5.5. +A React TypeScript web interface for the NGINX Declarative API v5.5, providing a form-driven UI for creating and submitting NGINX configurations. -## ๐Ÿ“ฆ Deliverables +## ๐Ÿ“ฆ Core Features -### 1. **Complete React TypeScript Application** +- Form-driven NGINX configuration builder (HTTP, Layer 4, Output) +- API Gateway editor with OpenAPI schema support +- Profile management (rate limiting, authentication, authorization, caching) +- Real-time JSON preview and submission +- Toast notifications for user feedback +- Dark-themed responsive UI -- Modern React 19 with TypeScript -- Vite build system for fast development -- Fully typed with comprehensive type definitions -- Responsive dark-themed UI - -### 2. **Core Features** - -- โœ… JWT authentication with localStorage -- โœ… Protected routes and auto-logout -- โœ… Dashboard with configuration overview -- โœ… JSON editor for creating configurations -- โœ… Real-time status monitoring -- โœ… Toast notifications for user feedback - -### 3. **API Integration** - -- All v5.5 endpoints integrated -- Axios client with request/response interceptors -- React Query for server state management -- Automatic error handling and retries -- Status polling for async operations - -### 4. **Testing Suite** - -- Vitest + React Testing Library -- Component tests -- Store tests -- Hook tests -- Coverage reporting configured - -### 5. **Docker & Deployment** - -- Multi-stage Dockerfile for optimized builds -- NGINX configuration for production serving -- Integrated with docker-compose -- Configurable ports via environment variables -- Proxy configuration for API requests - -### 6. **Documentation** - -- Comprehensive README -- Quick start guide -- Implementation details -- API documentation -- Troubleshooting guide - -## ๐Ÿ“ Files Created +## ๐Ÿ“ Source Structure ```text webui/ โ”œโ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ api/config.ts # API client โ”‚ โ”œโ”€โ”€ components/ +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.tsx # Root config form (thin wrapper) +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.css # Form styles โ”‚ โ”‚ โ”œโ”€โ”€ Header.tsx # Navigation header โ”‚ โ”‚ โ”œโ”€โ”€ Header.css -โ”‚ โ”‚ โ”œโ”€โ”€ Layout.tsx # Page layout +โ”‚ โ”‚ โ”œโ”€โ”€ Layout.tsx # Page layout wrapper โ”‚ โ”‚ โ”œโ”€โ”€ Layout.css -โ”‚ โ”‚ โ””โ”€โ”€ ProtectedRoute.tsx # Route guard -โ”‚ โ”œโ”€โ”€ hooks/useConfig.ts # React Query hooks -โ”‚ โ”œโ”€โ”€ lib/axios.ts # Axios instance +โ”‚ โ”‚ โ””โ”€โ”€ configForm/ # Config form modules +โ”‚ โ”‚ โ”œโ”€โ”€ types.ts # All TypeScript interfaces +โ”‚ โ”‚ โ”œโ”€โ”€ defaults.ts # Factory functions, parseConfig, toJson +โ”‚ โ”‚ โ”œโ”€โ”€ primitives.tsx # Shared UI primitives (Field, TextInput, etc.) +โ”‚ โ”‚ โ”œโ”€โ”€ ApiGatewayEditor.tsx # API Gateway location editor +โ”‚ โ”‚ โ”œโ”€โ”€ LocationEditors.tsx # Location sub-editors (auth, cache, etc.) +โ”‚ โ”‚ โ”œโ”€โ”€ LocationsEditor.tsx # Locations list editor +โ”‚ โ”‚ โ”œโ”€โ”€ TlsEditor.tsx # TLS configuration editor +โ”‚ โ”‚ โ”œโ”€โ”€ ServersSection.tsx # HTTP servers section +โ”‚ โ”‚ โ”œโ”€โ”€ UpstreamsSection.tsx # HTTP upstreams section +โ”‚ โ”‚ โ”œโ”€โ”€ ProfilesSection.tsx # HTTP profiles (rate limit, auth, etc.) +โ”‚ โ”‚ โ”œโ”€โ”€ HttpSection.tsx # HTTP section wrapper +โ”‚ โ”‚ โ”œโ”€โ”€ OutputSection.tsx # Output target section (NIM / NGINX One) +โ”‚ โ”‚ โ””โ”€โ”€ Layer4Section.tsx # Layer 4 TCP/UDP section โ”‚ โ”œโ”€โ”€ pages/ -โ”‚ โ”‚ โ”œโ”€โ”€ LoginPage.tsx # Login page -โ”‚ โ”‚ โ”œโ”€โ”€ LoginPage.css -โ”‚ โ”‚ โ”œโ”€โ”€ DashboardPage.tsx # Main dashboard -โ”‚ โ”‚ โ”œโ”€โ”€ DashboardPage.css -โ”‚ โ”‚ โ”œโ”€โ”€ CreateConfigPage.tsx # Config creator +โ”‚ โ”‚ โ”œโ”€โ”€ CreateConfigPage.tsx # Main (and only) page โ”‚ โ”‚ โ””โ”€โ”€ CreateConfigPage.css -โ”‚ โ”œโ”€โ”€ store/authStore.ts # Auth state โ”‚ โ”œโ”€โ”€ test/ -โ”‚ โ”‚ โ”œโ”€โ”€ setup.ts # Test config -โ”‚ โ”‚ โ”œโ”€โ”€ LoginPage.test.tsx # Component test -โ”‚ โ”‚ โ”œโ”€โ”€ authStore.test.ts # Store test -โ”‚ โ”‚ โ””โ”€โ”€ useConfig.test.tsx # Hook test -โ”‚ โ”œโ”€โ”€ types/index.ts # Type definitions -โ”‚ โ”œโ”€โ”€ App.tsx # Main app -โ”‚ โ”œโ”€โ”€ main.tsx # Entry point +โ”‚ โ”‚ โ”œโ”€โ”€ setup.ts # Test setup (IntersectionObserver + clipboard polyfills) +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.agw.test.tsx # AGW section toggle + OpenAPI schema tests +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.agw.validation.test.tsx # AGW field validation tests +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.agw.profiles.test.tsx # Profile dropdown tests +โ”‚ โ”‚ โ”œโ”€โ”€ ConfigForm.output.test.tsx # OutputSection โ€” type cards, license, resolver, sidebar, clipboard +โ”‚ โ”‚ โ””โ”€โ”€ CreateConfigPage.test.tsx # Page-level integration tests +โ”‚ โ”œโ”€โ”€ types/index.ts # Shared type definitions +โ”‚ โ”œโ”€โ”€ App.tsx # App entry โ€” single route to CreateConfigPage +โ”‚ โ”œโ”€โ”€ main.tsx # React entry point โ”‚ โ””โ”€โ”€ index.css # Global styles โ”œโ”€โ”€ Dockerfile # Production build -โ”œโ”€โ”€ nginx.conf # NGINX config +โ”œโ”€โ”€ nginx.conf # NGINX serving config โ”œโ”€โ”€ vite.config.ts # Vite config -โ”œโ”€โ”€ tsconfig.json # TS config -โ”œโ”€โ”€ tsconfig.node.json # TS node config +โ”œโ”€โ”€ tsconfig.json # TypeScript config โ”œโ”€โ”€ package.json # Dependencies -โ”œโ”€โ”€ .eslintrc.cjs # Linting -โ”œโ”€โ”€ .prettierrc # Formatting -โ”œโ”€โ”€ .gitignore # Git ignore -โ”œโ”€โ”€ index.html # HTML template -โ”œโ”€โ”€ README.md # Documentation -โ”œโ”€โ”€ QUICKSTART.md # Quick start guide -โ””โ”€โ”€ IMPLEMENTATION.md # Implementation details +โ””โ”€โ”€ index.html # HTML template ``` -## ๐Ÿ”„ Updated Files +## ๐Ÿš€ How to Use -```text -contrib/docker-compose/ -โ”œโ”€โ”€ docker-compose.yaml # Added webui service -โ”œโ”€โ”€ nginx-dapi.sh # Added -w flag for webui port -โ””โ”€โ”€ README.md # Updated with webui info +### Development + +```bash +cd webui +npm install +npm run dev ``` -## ๐Ÿš€ How to Use +Access at: -### Quick Start +### Production (Docker Compose) ```bash cd contrib/docker-compose @@ -119,87 +81,61 @@ cd contrib/docker-compose ./nginx-dapi.sh -c start ``` -Access at: - -### Development - -```bash -cd webui -npm install -npm run dev -``` +Access at: ### Testing ```bash +cd webui npm test npm run test:coverage ``` -## ๐ŸŽจ UI Features - -### Pages +## ๐ŸŽจ UI Structure -1. **Login** (`/login`) - - JWT token input - - Security warning - - Auto-redirect after login +### Page -2. **Dashboard** (`/`) - - Configuration cards grid - - Status indicators - - Quick actions (view, delete) - - Create new button +**Create Config** (`/`) โ€” the single page of the application. -3. **Create Config** (`/create`) - - JSON editor with syntax highlighting - - Template suggestions - - Validation - - Submit/cancel actions +- Output section (NGINX Instance Manager or NGINX One Console target, license with JWT upload) +- HTTP section includes policies (WAF), TLS certificates, and log profiles (at declaration level per v5.6 schema) +- HTTP section (profiles, servers, upstreams โ€” resolver field uses a ProfileSelect dropdown) +- Layer 4 section (TCP/UDP servers and upstreams) +- Sticky sidebar navigation with active-section highlighting +- Submit button sends the generated JSON to the API ### Components -- **Header** - Navigation and logout -- **Layout** - Consistent page structure -- **ProtectedRoute** - Auth guard - -## ๐Ÿ”’ Security Implementation - -### Current (Development) - -- JWT in localStorage -- Bearer token authentication -- Auto-logout on 401 - -### Recommended (Production) - -- HTTP-only cookies -- Token refresh mechanism -- CSRF protection -- Rate limiting -- HTTPS only +- **Header** โ€” application title bar +- **Layout** โ€” consistent page wrapper +- **ConfigForm** โ€” root form, composed from 13 focused sub-modules in `configForm/` ## ๐Ÿ“Š Technology Stack -|Layer|Technology| -|-|-| -|Framework|React 19| -|Language|TypeScript| -|Build|Vite| -|Routing|React Router v7| -|State|Zustand + TanStack Query| -|HTTP|Axios| -|Styling|CSS3 with custom properties| -|Testing|Vitest + Testing Library| -|Container|Docker + NGINX| +| Layer | Technology | +|---|---| +| Framework | React 19 | +| Language | TypeScript | +| Build | Vite | +| Routing | React Router v7 | +| HTTP | Fetch / Axios | +| Styling | CSS3 with custom properties | +| Testing | Vitest + Testing Library | +| Container | Docker + NGINX | ## ๐Ÿงช Test Coverage -- โœ… Authentication store -- โœ… Login page rendering -- โœ… API hooks -- โœ… Component integration -- ๐Ÿ“Š Ready for expanded coverage +- โœ… API Gateway section toggle behaviour +- โœ… OpenAPI schema URL/file/base64 modes +- โœ… Field validation (required errors) +- โœ… Profile dropdown population from HTTP-level profiles +- โœ… Live profile name propagation to API Gateway dropdowns +- โœ… Page-level integration (submit, JSON editor round-trip) +- โœ… Output type card labels (NGINX Instance Manager / NGINX One Console) +- โœ… License section toggle, grace_period boolean, JWT file upload +- โœ… Resolver ProfileSelect dropdown population and emission +- โœ… Sidebar navigation rendering and indented subsection links +- โœ… Clipboard execCommand fallback (non-secure context) ## ๐ŸŒ API Endpoints Used @@ -210,71 +146,8 @@ npm run test:coverage - `GET /v5.5/config/{configUid}/status` - `GET /v5.5/config/{configUid}/submission/{submissionUid}` -## ๐Ÿ“ Key Features - -โœ… Modern React architecture -โœ… Full TypeScript coverage -โœ… Comprehensive error handling -โœ… Real-time status updates -โœ… Responsive design -โœ… Dark theme UI -โœ… Toast notifications -โœ… Protected routes -โœ… Docker deployment -โœ… Automated tests -โœ… Complete documentation - -## ๐ŸŽฏ Production Checklist - -Before deploying to production: - -- [ ] Configure proper JWT validation on backend -- [ ] Implement HTTP-only cookie authentication -- [ ] Add token refresh mechanism -- [ ] Enable HTTPS/TLS -- [ ] Set up CORS properly -- [ ] Add rate limiting -- [ ] Enable security headers -- [ ] Configure CSP -- [ ] Add monitoring/logging -- [ ] Run security audit -- [ ] Perform load testing -- [ ] Review error handling -- [ ] Configure backups - ## ๐Ÿ“š Documentation -1. **README.md** - Comprehensive guide -2. **QUICKSTART.md** - 5-minute setup -3. **IMPLEMENTATION.md** - Technical details -4. **Inline comments** - Code documentation - -## ๐Ÿ”ง Customization - -The UI is designed to be easily customizable: - -- **Colors:** Update CSS variables in `index.css` -- **API endpoint:** Modify `vite.config.ts` and `nginx.conf` -- **Features:** Add new pages in `src/pages/` -- **Types:** Extend `src/types/index.ts` - -## ๐ŸŽ‰ Ready to Deploy - -The Web UI is fully functional and ready for: - -- โœ… Local development -- โœ… Docker deployment -- โœ… Testing -- โœ… Production (with security enhancements) - -## ๐Ÿ“ž Support - -- ๐Ÿ“– Documentation in `/webui/README.md` -- ๐Ÿš€ Quick start in `/webui/QUICKSTART.md` -- ๐Ÿ” Implementation details in `/webui/IMPLEMENTATION.md` - ---- - -Status: โœ… Complete and Ready - -The NGINX Declarative API now has a modern, professional web interface that makes configuration management intuitive and efficient! +1. **README.md** โ€” Overview and setup instructions +2. **QUICKSTART.md** โ€” 5-minute getting started guide +3. **IMPLEMENTATION.md** โ€” Technical implementation details diff --git a/webui/index.html b/webui/index.html index 5ef838ac..ef2370bd 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2,7 +2,7 @@ - + NGINX Declarative API diff --git a/webui/public/favicon.ico b/webui/public/favicon.ico new file mode 100644 index 00000000..c509adfe Binary files /dev/null and b/webui/public/favicon.ico differ diff --git a/webui/src/components/ConfigForm.css b/webui/src/components/ConfigForm.css index 88358353..0bfa8a49 100644 --- a/webui/src/components/ConfigForm.css +++ b/webui/src/components/ConfigForm.css @@ -2,11 +2,70 @@ Polished Hardcoded Form โ€” ConfigForm ============================================================ */ +/* โ”€โ”€โ”€ Two-column layout: sidebar + content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +.cf-layout { + display: flex; + gap: 1rem; + align-items: flex-start; + width: 100%; +} + +/* Sticky sidebar nav */ +.cf-sidenav { + flex: 0 0 auto; + width: 150px; + position: sticky; + top: calc(var(--header-height, 5rem) + 1rem); + display: flex; + flex-direction: column; + gap: 0.15rem; + background: var(--cf-surface-section, rgba(255,255,255,0.04)); + border: 1px solid var(--border-n1, rgba(255,255,255,0.1)); + border-radius: 9px; + padding: 0.6rem 0.4rem; +} + +.cf-sidenav-item { + display: block; + width: 100%; + text-align: left; + background: none; + border: none; + border-radius: 5px; + padding: 0.32rem 0.6rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--text-45, rgba(255,255,255,0.45)); + cursor: pointer; + transition: background 0.12s, color 0.12s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cf-sidenav-item.indent { + padding-left: 1.1rem; + font-size: 0.7rem; + font-weight: 400; +} + +.cf-sidenav-item:hover { + background: rgba(255,255,255,0.07); + color: var(--text-88, rgba(255,255,255,0.88)); +} + +.cf-sidenav-item.active { + background: rgba(0, 150, 57, 0.18); + color: #00bf47; + font-weight: 600; +} + .config-form { + flex: 1; + min-width: 0; display: flex; flex-direction: column; gap: 0.6rem; - width: 100%; } /* โ”€โ”€โ”€ Sections โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ @@ -369,3 +428,152 @@ select.cf-input option { background: var(--cf-surface-select-option); } margin: 0; } .cf-empty-top { margin-top: -0.25rem; } + +/* --- API Gateway Editor ---------------------------------------------------- */ + +.cf-subsection-agw { + border-top: 1px solid var(--border-accent-1); + margin-top: 0.875rem; + padding-top: 0.75rem; +} + +.cf-agw-card { + border: 1px solid var(--border-accent-1); + border-radius: 7px; + margin-bottom: 0.4rem; + overflow: hidden; +} + +.cf-agw-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.875rem; + background: var(--surface-form-input); +} + +.cf-agw-card-title { + font-size: 0.75rem; + font-weight: 600; + color: var(--text-45); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.cf-agw-card-body { + padding: 0.75rem 0.875rem; + border-top: 1px solid var(--border-accent-1); + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.cf-agw-toggles { + display: flex; + flex-direction: column; + gap: 0.4rem; + padding-top: 0.25rem; +} + +.cf-agw-item-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 0.65rem; + align-items: start; + padding: 0.65rem 0.875rem; + border-top: 1px solid var(--border-accent-1); +} + +.cf-agw-item-remove { + display: flex; + align-items: flex-start; + padding-top: 1.55rem; +} + +.cf-empty-sm { + font-size: 0.74rem; + color: var(--text-empty); + font-style: italic; + margin: 0; + padding: 0.5rem 0.875rem; +} + +.cf-textarea-sm { + min-height: 62px; + font-size: 0.8rem; + resize: vertical; + line-height: 1.4; +} + +/* 3-column grid for location basic fields */ +.cf-grid-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.75rem; +} +@media (max-width: 900px) { + .cf-grid-3 { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 600px) { + .cf-grid-3 { grid-template-columns: 1fr; } +} + +/* OpenAPI schema URL / file mode switcher */ +.cf-agw-schema-mode { + display: flex; + gap: 0; + border: 1px solid var(--border-accent-1); + border-radius: 5px; + overflow: hidden; + align-self: flex-start; +} + +.cf-agw-mode-btn { + padding: 0.25rem 0.7rem; + font-size: 0.78rem; + font-weight: 500; + cursor: pointer; + border: none; + background: var(--surface-form-input); + color: var(--text-45); + transition: background 0.15s, color 0.15s; +} + +.cf-agw-mode-btn + .cf-agw-mode-btn { + border-left: 1px solid var(--border-accent-1); +} + +.cf-agw-mode-btn.active { + background: var(--accent-blue, #3b82f6); + color: #fff; +} + +.cf-agw-file-row { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.cf-agw-file-input { + font-size: 0.8rem; + color: var(--text-main); +} + +.cf-agw-file-ok { + font-size: 0.75rem; + color: var(--text-success, #22c55e); + font-style: italic; +} + +/* Validation */ +.cf-input-error { + border-color: #ef4444 !important; + outline-color: #ef4444; +} + +.cf-field-error { + font-size: 0.72rem; + color: #ef4444; + margin: 0.15rem 0 0; +} diff --git a/webui/src/components/ConfigForm.tsx b/webui/src/components/ConfigForm.tsx index 52afb5c6..9e56999a 100644 --- a/webui/src/components/ConfigForm.tsx +++ b/webui/src/components/ConfigForm.tsx @@ -1,775 +1,124 @@ import { useState, useEffect, useCallback } from 'react'; import './ConfigForm.css'; +import type { ConfigData } from './configForm/types'; +import { parseConfig, toJson, emptyNms, emptyNginxOne } from './configForm/defaults'; +import { OutputSection } from './configForm/OutputSection'; +import { HttpSection } from './configForm/HttpSection'; +import { Layer4Section } from './configForm/Layer4Section'; -// โ”€โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -interface Origin { - server: string; - weight?: number; - max_fails?: number; - fail_timeout?: string; - backup?: boolean; -} - -interface Location { - uri: string; - urimatch?: string; - upstream?: string; -} - -interface TlsConfig { - certificate?: string; - key?: string; - protocols?: string[]; -} - -interface Server { - name: string; - names?: string[]; - listen?: { address?: string; http2?: boolean; tls?: TlsConfig }; - locations?: Location[]; -} - -interface Upstream { - name: string; - origin: Origin[]; - resolver?: string; -} - -interface OutputNMS { - url: string; - username: string; - password: string; - instancegroup: string; - synctime?: number; - modules?: string[]; -} - -interface OutputNGINXOne { - url: string; - namespace: string; - token: string; - configsyncgroup: string; - synctime?: number; - modules?: string[]; -} - -interface ConfigData { - output: { - type: 'nms' | 'nginxone'; - nms?: OutputNMS; - nginxone?: OutputNGINXOne; - }; +const DEFAULT_CFG: ConfigData = { + output: { type: 'nim', synchronous: true, nms: emptyNms(), nginxone: emptyNginxOne() }, declaration: { - http?: { servers?: Server[]; upstreams?: Upstream[] }; - layer4?: { - servers?: Array<{ name: string; listen?: { address?: string }; upstream?: string }>; - upstreams?: Array<{ name: string; origin?: Array<{ server: string }> }>; - }; - }; -} - -// โ”€โ”€โ”€ Defaults โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -const emptyNms = (): OutputNMS => ({ - url: '', username: '', password: '', instancegroup: '', synctime: 0, modules: [], -}); -const emptyNginxOne = (): OutputNGINXOne => ({ - url: '', namespace: '', token: '', configsyncgroup: '', synctime: 0, modules: [], -}); -const emptyServer = (): Server => ({ - name: '', names: [], listen: { address: '', http2: false }, locations: [], -}); -const emptyLocation = (): Location => ({ uri: '/', urimatch: 'prefix', upstream: '' }); -const emptyUpstream = (): Upstream => ({ name: '', origin: [{ server: '', weight: 1 }] }); -const emptyOrigin = (): Origin => ({ server: '', weight: 1, max_fails: 1, fail_timeout: '10s' }); - -function parseConfig(json: string): ConfigData | null { - try { - const p = JSON.parse(json); - return { - output: { - type: p?.output?.type ?? 'nms', - nms: { ...emptyNms(), ...(p?.output?.nms ?? {}) }, - nginxone: { ...emptyNginxOne(), ...(p?.output?.nginxone ?? {}) }, - }, - declaration: { - http: { - servers: p?.declaration?.http?.servers ?? [], - upstreams: p?.declaration?.http?.upstreams ?? [], - }, - layer4: { - servers: p?.declaration?.layer4?.servers ?? [], - upstreams: p?.declaration?.layer4?.upstreams ?? [], - }, - }, - }; - } catch { - return null; - } -} - -function toJson(cfg: ConfigData): string { - const out: Record = { type: cfg.output.type }; - if (cfg.output.type === 'nms') out.nms = cfg.output.nms; - else out.nginxone = cfg.output.nginxone; - - const decl: Record = {}; - const http = cfg.declaration.http; - if ((http?.servers?.length ?? 0) + (http?.upstreams?.length ?? 0) > 0) { - decl.http = { - ...(http?.servers?.length ? { servers: http.servers } : {}), - ...(http?.upstreams?.length ? { upstreams: http.upstreams } : {}), - }; - } - const l4 = cfg.declaration.layer4; - if ((l4?.servers?.length ?? 0) + (l4?.upstreams?.length ?? 0) > 0) { - decl.layer4 = { - ...(l4?.servers?.length ? { servers: l4.servers } : {}), - ...(l4?.upstreams?.length ? { upstreams: l4.upstreams } : {}), - }; - } - - return JSON.stringify({ output: out, declaration: decl }, null, 2); -} - -// โ”€โ”€โ”€ Primitive field components โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function Field({ - label, required, hint, children, span, -}: { - label: string; required?: boolean; hint?: string; children: React.ReactNode; span?: 'full'; -}) { - return ( -
- - {children} - {hint &&

{hint}

} -
- ); -} - -function TextInput({ - value, onChange, placeholder, type = 'text', mono, -}: { - value: string; onChange: (v: string) => void; placeholder?: string; type?: string; mono?: boolean; -}) { - return ( - onChange(e.target.value)} - /> - ); -} - -function NumberInput({ value, onChange, min = 0 }: { value: number; onChange: (v: number) => void; min?: number }) { - return ( - onChange(Number(e.target.value))} - /> - ); -} - -function SelectInput({ value, onChange, options }: { - value: string; onChange: (v: string) => void; - options: { value: string; label: string }[]; -}) { - return ( - - ); -} - -function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label: string }) { - return ( - - ); -} - -const MODULES = [ - { value: 'ngx_http_app_protect_module', label: 'NGINX App Protect WAF' }, - { value: 'ngx_http_js_module', label: 'NGINX JavaScript (njs) โ€” HTTP' }, - { value: 'ngx_stream_js_module', label: 'NGINX JavaScript (njs) โ€” Stream' }, - { value: 'ngx_http_geoip2_module', label: 'GeoIP2' }, -]; - -function ModulesField({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) { - const toggle = (mod: string) => - onChange(value.includes(mod) ? value.filter(m => m !== mod) : [...value, mod]); - return ( -
- {MODULES.map(m => ( - - ))} -
- ); -} - -// โ”€โ”€โ”€ Section chrome โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function SectionTitle({ title, sub }: { title: string; sub?: string }) { - return ( -
-
- {title} - {sub && {sub}} -
-
- ); -} + certificates: [], + http: { servers: [], upstreams: [], rate_limit: [], authentication: { client: [] }, authorization: [], cache: [], maps: [], logformats: [], njs: [], njs_profiles: [], acme_issuers: [], policies: [], log_profiles: [] }, + layer4: { servers: [], upstreams: [] }, + resolvers: [], + }, +}; -function AddBtn({ label, onClick }: { label: string; onClick: () => void }) { - return ( - - ); +interface ConfigFormProps { + initialJson: string; + onChange: (json: string) => void; } -function RemoveBtn({ onClick }: { onClick: () => void }) { - return ( - - ); +function scrollToSection(id: string) { + const el = document.getElementById(id); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); } -// โ”€โ”€โ”€ Collapsible card โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function CollapseCard({ - title, meta, children, defaultOpen, -}: { - title: React.ReactNode; meta?: string; children: React.ReactNode; defaultOpen?: boolean; -}) { - const [open, setOpen] = useState(defaultOpen ?? false); - return ( -
-
setOpen(o => !o)}> - {open ? 'โ–พ' : 'โ–ธ'} - {title} - {meta && {meta}} -
- {open &&
{children}
} -
- ); +interface NavItem { + id: string; + label: string; + indent?: boolean; } -// โ”€โ”€โ”€ Locations sub-editor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -const URI_MATCH_OPTIONS = [ - { value: 'prefix', label: 'Prefix โ€” matches any path starting with URI' }, - { value: 'exact', label: 'Exact โ€” matches only this exact URI' }, - { value: 'regex', label: 'Regex โ€” case-sensitive regular expression' }, - { value: 'iregex', label: 'iRegex โ€” case-insensitive regular expression' }, - { value: 'best', label: 'Best โ€” longest prefix + regex priority' }, +const NAV_ITEMS: NavItem[] = [ + { id: 'cf-sec-output', label: 'Output' }, + { id: 'cf-sec-license', label: 'License', indent: true }, + { id: 'cf-sec-http', label: 'HTTP' }, + { id: 'cf-sec-http-profiles', label: 'Profiles', indent: true }, + { id: 'cf-sec-http-servers', label: 'Servers', indent: true }, + { id: 'cf-sec-http-upstreams', label: 'Upstreams', indent: true }, + { id: 'cf-sec-certificates', label: 'Certificates', indent: true }, + { id: 'cf-sec-policies', label: 'Policies', indent: true }, + { id: 'cf-sec-log-profiles', label: 'Log Profiles', indent: true }, + { id: 'cf-sec-layer4', label: 'Layer 4' }, ]; -function LocationsEditor({ locations, onChange }: { locations: Location[]; onChange: (l: Location[]) => void }) { - const update = (i: number, l: Location) => onChange(locations.map((x, idx) => idx === i ? l : x)); - const remove = (i: number) => onChange(locations.filter((_, idx) => idx !== i)); - - return ( -
-
- Locations - onChange([...locations, emptyLocation()])} /> -
- {locations.length === 0 && ( -

No locations โ€” add one to route traffic to an upstream.

- )} - {locations.map((loc, i) => ( -
-
#{i + 1}
-
- - update(i, { ...loc, uri: v })} placeholder="/" mono /> - - - update(i, { ...loc, urimatch: v })} - options={URI_MATCH_OPTIONS} - /> - - - update(i, { ...loc, upstream: v })} placeholder="http://my-upstream" mono /> - -
- remove(i)} /> -
- ))} -
- ); -} - -// โ”€โ”€โ”€ TLS sub-editor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function TlsEditor({ tls, onChange }: { - tls: TlsConfig | undefined; - onChange: (t: TlsConfig | undefined) => void; -}) { - const enabled = !!tls?.certificate || !!tls?.key; - const [show, setShow] = useState(enabled); - const t = tls ?? {}; - - const handleEnable = (v: boolean) => { - setShow(v); - if (!v) onChange(undefined); - else onChange({ certificate: '', key: '', protocols: ['TLSv1.2', 'TLSv1.3'] }); - }; - - const TLS_PROTOCOL_OPTIONS = ['TLSv1.2', 'TLSv1.3', 'TLSv1.1']; - const toggleProto = (p: string) => { - const protos = t.protocols ?? []; - onChange({ ...t, protocols: protos.includes(p) ? protos.filter(x => x !== p) : [...protos, p] }); - }; - - return ( -
-
- TLS / SSL - -
- {show && ( -
- - onChange({ ...t, certificate: v })} placeholder="my-tls-cert" /> - - - onChange({ ...t, key: v })} placeholder="my-tls-key" /> - - -
- {TLS_PROTOCOL_OPTIONS.map(p => ( - - ))} -
-
-
- )} -
- ); -} - -// โ”€โ”€โ”€ HTTP Servers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function ServersSection({ servers, onChange }: { servers: Server[]; onChange: (s: Server[]) => void }) { - const update = (i: number, s: Server) => onChange(servers.map((x, idx) => idx === i ? s : x)); - const remove = (i: number) => onChange(servers.filter((_, idx) => idx !== i)); - - return ( -
-
- Servers - onChange([...servers, emptyServer()])} /> -
- - {servers.length === 0 && ( -

No HTTP servers defined. Add a server to start routing traffic.

- )} - - {servers.map((srv, i) => ( - server #{i + 1}} - meta={srv.listen?.address || 'โ€”'} - defaultOpen={i === 0 && servers.length === 1} - > -
- remove(i)} /> -
- -
- - update(i, { ...srv, name: v })} placeholder="my-server" /> - - - update(i, { ...srv, listen: { ...(srv.listen ?? {}), address: v } })} - placeholder="0.0.0.0:80" - mono - /> - - - update(i, { ...srv, names: v.split(/\s+/).filter(Boolean) })} - placeholder="example.com www.example.com" - mono - /> - -
- -
- update(i, { ...srv, listen: { ...(srv.listen ?? {}), http2: v } })} - label="Enable HTTP/2" - /> -

Enables HTTP/2 protocol support on this listener via the http2 directive.

-
- - update(i, { ...srv, listen: { ...(srv.listen ?? {}), tls: t } })} - /> - - update(i, { ...srv, locations: locs })} - /> -
- ))} -
- ); -} - -// โ”€โ”€โ”€ HTTP Upstreams โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function UpstreamsSection({ upstreams, onChange }: { upstreams: Upstream[]; onChange: (u: Upstream[]) => void }) { - const update = (i: number, u: Upstream) => onChange(upstreams.map((x, idx) => idx === i ? u : x)); - const remove = (i: number) => onChange(upstreams.filter((_, idx) => idx !== i)); - const updOrigin = (i: number, oi: number, o: Origin) => - update(i, { ...upstreams[i], origin: (upstreams[i].origin ?? []).map((x, oIdx) => oIdx === oi ? o : x) }); - const remOrigin = (i: number, oi: number) => - update(i, { ...upstreams[i], origin: (upstreams[i].origin ?? []).filter((_, oIdx) => oIdx !== oi) }); - const addOrigin = (i: number) => - update(i, { ...upstreams[i], origin: [...(upstreams[i].origin ?? []), emptyOrigin()] }); - - return ( -
-
- Upstreams - onChange([...upstreams, emptyUpstream()])} /> -
- - {upstreams.length === 0 && ( -

No upstreams defined. Add an upstream to reference from your server locations.

- )} - - {upstreams.map((up, i) => ( - upstream #{i + 1}} - meta={`${up.origin?.length ?? 0} origin${(up.origin?.length ?? 0) !== 1 ? 's' : ''}`} - defaultOpen={i === 0 && upstreams.length === 1} - > -
remove(i)} />
- -
- - update(i, { ...up, name: v })} placeholder="backend" /> - - - update(i, { ...up, resolver: v })} placeholder="my-resolver" /> - -
- -
-
- Origins - addOrigin(i)} /> -
-

Each origin is a backend server. NGINX will load-balance requests across all non-backup origins.

- {(up.origin ?? []).map((o, oi) => ( -
- - updOrigin(i, oi, { ...o, server: v })} placeholder="10.0.0.1:8080" mono /> - - - updOrigin(i, oi, { ...o, weight: v })} min={1} /> - - - updOrigin(i, oi, { ...o, max_fails: v })} min={0} /> - - - updOrigin(i, oi, { ...o, fail_timeout: v })} placeholder="10s" mono /> - - - updOrigin(i, oi, { ...o, backup: v })} label="Backup only" /> - -
remOrigin(i, oi)} />
-
- ))} -
-
- ))} -
- ); -} - -// โ”€โ”€โ”€ HTTP wrapper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function HttpSection({ servers, onServersChange, upstreams, onUpstreamsChange }: { - servers: Server[]; - onServersChange: (s: Server[]) => void; - upstreams: Upstream[]; - onUpstreamsChange: (u: Upstream[]) => void; -}) { - return ( -
- - - -
- ); -} - -// โ”€โ”€โ”€ Output Section โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function OutputSection({ output, onChange }: { - output: ConfigData['output']; - onChange: (o: ConfigData['output']) => void; -}) { - const nms = output.nms ?? emptyNms(); - const no = output.nginxone ?? emptyNginxOne(); - - return ( -
- - -
- - -
- - {output.type === 'nms' && ( -
- - onChange({ ...output, nms: { ...nms, url: v } })} placeholder="https://nms.example.com" mono /> - - - onChange({ ...output, nms: { ...nms, username: v } })} placeholder="admin" /> - - - onChange({ ...output, nms: { ...nms, password: v } })} type="password" placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" /> - - - onChange({ ...output, nms: { ...nms, instancegroup: v } })} placeholder="production" /> - - - onChange({ ...output, nms: { ...nms, synctime: v } })} /> - - - onChange({ ...output, nms: { ...nms, modules: v } })} /> - -
- )} - - {output.type === 'nginxone' && ( -
- - onChange({ ...output, nginxone: { ...no, url: v } })} placeholder="https://nginx-one.example.com" mono /> - - - onChange({ ...output, nginxone: { ...no, namespace: v } })} placeholder="default" /> - - - onChange({ ...output, nginxone: { ...no, token: v } })} type="password" placeholder="โ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ขโ€ข" /> - - - onChange({ ...output, nginxone: { ...no, configsyncgroup: v } })} placeholder="production-group" /> - - - onChange({ ...output, nginxone: { ...no, synctime: v } })} /> - - - onChange({ ...output, nginxone: { ...no, modules: v } })} /> - -
- )} -
- ); -} - -// โ”€โ”€โ”€ Layer 4 Section โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function L4ServersSection({ servers, onChange }: { - servers: NonNullable['servers']; - onChange: (s: NonNullable['servers']) => void; -}) { - const items = servers ?? []; - const update = (i: number, s: typeof items[0]) => onChange(items.map((x, idx) => idx === i ? s : x)); - const remove = (i: number) => onChange(items.filter((_, idx) => idx !== i)); - - return ( -
-
- Servers - onChange([...items, { name: '', listen: { address: '' }, upstream: '' }])} /> -
- {items.length === 0 && ( -

No L4 servers defined. Add one to proxy TCP/UDP connections.

- )} - {items.map((sv, i) => ( - server #{i + 1}} meta={sv.listen?.address || 'โ€”'}> -
remove(i)} />
- - update(i, { ...sv, name: v })} placeholder="l4-server" /> - - - update(i, { ...sv, listen: { address: v } })} placeholder="0.0.0.0:5432" mono /> - - - update(i, { ...sv, upstream: v })} placeholder="l4-backend" /> - -
- ))} -
- ); -} - -function L4UpstreamsSection({ upstreams, onChange }: { - upstreams: NonNullable['upstreams']; - onChange: (u: NonNullable['upstreams']) => void; -}) { - const items = upstreams ?? []; - const update = (i: number, u: typeof items[0]) => onChange(items.map((x, idx) => idx === i ? u : x)); - const remove = (i: number) => onChange(items.filter((_, idx) => idx !== i)); - - return ( -
-
- Upstreams - onChange([...items, { name: '', origin: [{ server: '' }] }])} /> -
- {items.length === 0 && ( -

No L4 upstreams defined. Add one to reference from an L4 server.

- )} - {items.map((up, i) => ( - upstream #{i + 1}} meta={`${up.origin?.length ?? 0} origin${(up.origin?.length ?? 0) !== 1 ? 's' : ''}`}> -
remove(i)} />
- - update(i, { ...up, name: v })} placeholder="l4-backend" /> - -
-
- Origins - update(i, { ...up, origin: [...(up.origin ?? []), { server: '' }] })} /> -
-

Backend server addresses for this upstream group.

- {(up.origin ?? []).map((o, oi) => ( -
- update(i, { ...up, origin: (up.origin ?? []).map((x, xIdx) => xIdx === oi ? { ...x, server: v } : x) })} - placeholder="10.0.0.1:5432" - mono - /> - update(i, { ...up, origin: (up.origin ?? []).filter((_, xIdx) => xIdx !== oi) })} /> -
- ))} -
-
- ))} -
- ); -} - -// โ”€โ”€โ”€ Layer 4 wrapper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -function Layer4Section({ servers, onServersChange, upstreams, onUpstreamsChange }: { - servers: NonNullable['servers']; - onServersChange: (s: NonNullable['servers']) => void; - upstreams: NonNullable['upstreams']; - onUpstreamsChange: (u: NonNullable['upstreams']) => void; -}) { - return ( -
- - - -
- ); -} - -// โ”€โ”€โ”€ Root component โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - -interface ConfigFormProps { - initialJson: string; - onChange: (json: string) => void; -} - -const DEFAULT_CFG: ConfigData = { - output: { type: 'nms', nms: emptyNms(), nginxone: emptyNginxOne() }, - declaration: { http: { servers: [], upstreams: [] }, layer4: { servers: [], upstreams: [] } }, -}; - export function ConfigForm({ initialJson, onChange }: ConfigFormProps) { const [cfg, setCfg] = useState(() => parseConfig(initialJson) ?? DEFAULT_CFG); + const [activeSection, setActiveSection] = useState('cf-sec-output'); useEffect(() => { const parsed = parseConfig(initialJson); if (parsed) setCfg(parsed); }, [initialJson]); + // Track active section via IntersectionObserver + useEffect(() => { + const ids = NAV_ITEMS.map(n => n.id); + const observers: IntersectionObserver[] = []; + const visible = new Set(); + + ids.forEach(id => { + const el = document.getElementById(id); + if (!el) return; + const obs = new IntersectionObserver( + entries => { + entries.forEach(e => { + if (e.isIntersecting) visible.add(id); else visible.delete(id); + }); + // Pick the topmost visible section + const first = ids.find(i => visible.has(i)); + if (first) setActiveSection(first); + }, + { rootMargin: '-10% 0px -80% 0px' } + ); + obs.observe(el); + observers.push(obs); + }); + return () => observers.forEach(o => o.disconnect()); + }, []); + const update = useCallback((next: ConfigData) => { setCfg(next); onChange(toJson(next)); }, [onChange]); - return ( -
- update({ ...cfg, output: o })} /> - update({ ...cfg, declaration: { ...cfg.declaration, http: { ...cfg.declaration.http, servers: s } } })} - upstreams={cfg.declaration.http?.upstreams ?? []} - onUpstreamsChange={u => update({ ...cfg, declaration: { ...cfg.declaration, http: { ...cfg.declaration.http, upstreams: u } } })} - /> - update({ ...cfg, declaration: { ...cfg.declaration, layer4: { ...cfg.declaration.layer4, servers: s } } })} - upstreams={cfg.declaration.layer4?.upstreams} - onUpstreamsChange={u => update({ ...cfg, declaration: { ...cfg.declaration, layer4: { ...cfg.declaration.layer4, upstreams: u } } })} - /> + const http = cfg.declaration.http ?? {}; + + return ( +
+ +
+ update({ ...cfg, output: o })} /> + update({ ...cfg, declaration: { ...cfg.declaration, http: h } })} + resolvers={cfg.declaration.resolvers ?? []} + onResolversChange={v => update({ ...cfg, declaration: { ...cfg.declaration, resolvers: v } })} + certificates={cfg.declaration.certificates ?? []} + onCertificatesChange={v => update({ ...cfg, declaration: { ...cfg.declaration, certificates: v } })} + outputType={cfg.output.type} + /> + update({ ...cfg, declaration: { ...cfg.declaration, layer4: { ...cfg.declaration.layer4, servers: s } } })} + upstreams={cfg.declaration.layer4?.upstreams} + onUpstreamsChange={u => update({ ...cfg, declaration: { ...cfg.declaration, layer4: { ...cfg.declaration.layer4, upstreams: u } } })} + /> +
); } diff --git a/webui/src/components/configForm/ApiGatewayEditor.tsx b/webui/src/components/configForm/ApiGatewayEditor.tsx new file mode 100644 index 00000000..87904817 --- /dev/null +++ b/webui/src/components/configForm/ApiGatewayEditor.tsx @@ -0,0 +1,471 @@ +import { useState, useRef, type ReactNode } from 'react'; +import type { APIGateway, HttpProfiles, AGWDeveloperPortal } from './types'; +import { emptyAGWRateLimit, emptyAGWAuthorization, emptyAGWCache, emptyAGWVisibility } from './defaults'; +import { Field, TextInput, NumberInput, SelectInput, ProfileSelect, Toggle, AddBtn, RemoveBtn } from './primitives'; + +export function PathsInput({ + value, onChange, placeholder, +}: { value: string[]; onChange: (v: string[]) => void; placeholder?: string }) { + return ( +