init
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,13 @@
|
||||
# Stage 1: Build
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Serve with nginx
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
# Personal Internet Cell - Web UI
|
||||
|
||||
A modern React-based web interface for managing your Personal Internet Cell.
|
||||
|
||||
## Features
|
||||
|
||||
- **Dashboard**: Overview of cell status and services
|
||||
- **Peer Management**: Add, remove, and configure WireGuard peers
|
||||
- **Network Services**: DNS, DHCP, and NTP management
|
||||
- **WireGuard**: VPN configuration and status
|
||||
- **Email Services**: Postfix and Dovecot management
|
||||
- **Calendar Services**: Radicale CalDAV/CardDAV management
|
||||
- **File Storage**: WebDAV file storage management
|
||||
- **Routing**: Advanced VPN gateway and routing configuration
|
||||
- **Logs**: System logs and monitoring
|
||||
- **Settings**: Cell configuration and security settings
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **React 19**: Modern React with hooks
|
||||
- **Vite**: Fast build tool and dev server
|
||||
- **Tailwind CSS**: Utility-first CSS framework
|
||||
- **Lucide React**: Beautiful icons
|
||||
- **React Router**: Client-side routing
|
||||
- **Axios**: HTTP client for API communication
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- Personal Internet Cell backend running on port 3000
|
||||
|
||||
### Setup
|
||||
|
||||
1. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. Start the development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
3. Open your browser to `http://localhost:5173`
|
||||
|
||||
### Development Features
|
||||
|
||||
- **Hot Reload**: Changes reflect immediately
|
||||
- **API Proxy**: Requests to `/api/*` are proxied to `http://localhost:3000`
|
||||
- **TypeScript Support**: Full TypeScript support available
|
||||
- **ESLint**: Code linting and formatting
|
||||
|
||||
## Building for Production
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
This creates a `dist/` directory with optimized production files.
|
||||
|
||||
### Preview
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
```
|
||||
|
||||
This serves the built files locally for testing.
|
||||
|
||||
## API Integration
|
||||
|
||||
The Web UI communicates with the Personal Internet Cell backend API:
|
||||
|
||||
- **Base URL**: `http://localhost:3000` (development)
|
||||
- **Health Check**: `/health`
|
||||
- **API Endpoints**: `/api/*`
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create a `.env` file to customize the API URL:
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # Reusable UI components
|
||||
│ └── Sidebar.jsx # Navigation sidebar
|
||||
├── pages/ # Page components
|
||||
│ ├── Dashboard.jsx # Main dashboard
|
||||
│ ├── Peers.jsx # Peer management
|
||||
│ ├── NetworkServices.jsx
|
||||
│ ├── WireGuard.jsx # VPN configuration
|
||||
│ ├── Email.jsx # Email services
|
||||
│ ├── Calendar.jsx # Calendar services
|
||||
│ ├── Files.jsx # File storage
|
||||
│ ├── Routing.jsx # Routing configuration
|
||||
│ ├── Logs.jsx # System logs
|
||||
│ └── Settings.jsx # Cell settings
|
||||
├── services/ # API services
|
||||
│ └── api.js # API client and endpoints
|
||||
├── App.jsx # Main app component
|
||||
├── main.jsx # App entry point
|
||||
└── index.css # Global styles
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
The Web UI uses Tailwind CSS with custom components:
|
||||
|
||||
- **Cards**: `.card` for content containers
|
||||
- **Buttons**: `.btn`, `.btn-primary`, `.btn-secondary`, etc.
|
||||
- **Inputs**: `.input` for form fields
|
||||
- **Status Indicators**: `.status-indicator`, `.status-online`, etc.
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome 90+
|
||||
- Firefox 88+
|
||||
- Safari 14+
|
||||
- Edge 90+
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Follow the existing code style
|
||||
2. Use TypeScript for new components
|
||||
3. Add tests for new features
|
||||
4. Update documentation as needed
|
||||
|
||||
## License
|
||||
|
||||
Part of the Personal Internet Cell project.
|
||||
@@ -0,0 +1,29 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
ecmaFeatures: { jsx: true },
|
||||
sourceType: 'module',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "personal-internet-cell-webui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.22.0",
|
||||
"axios": "^1.6.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"clsx": "^2.0.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.30.1",
|
||||
"@types/react": "^18.2.62",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"eslint": "^9.30.1",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^16.3.0",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Home,
|
||||
Users,
|
||||
Network,
|
||||
Shield,
|
||||
Mail,
|
||||
Calendar as CalendarIcon,
|
||||
FolderOpen,
|
||||
Activity,
|
||||
Wifi,
|
||||
Server,
|
||||
Key,
|
||||
Package2,
|
||||
Settings as SettingsIcon
|
||||
} from 'lucide-react';
|
||||
import { healthAPI } from './services/api';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Peers from './pages/Peers';
|
||||
import NetworkServices from './pages/NetworkServices';
|
||||
import WireGuard from './pages/WireGuard';
|
||||
import Email from './pages/Email';
|
||||
import Calendar from './pages/Calendar';
|
||||
import Files from './pages/Files';
|
||||
import Routing from './pages/Routing';
|
||||
import Logs from './pages/Logs';
|
||||
import Settings from './pages/Settings';
|
||||
import Vault from './pages/Vault';
|
||||
import ContainerDashboard from './components/ContainerDashboard';
|
||||
|
||||
function App() {
|
||||
const [isOnline, setIsOnline] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
await healthAPI.check();
|
||||
setIsOnline(true);
|
||||
} catch (error) {
|
||||
console.error('Backend not available:', error);
|
||||
setIsOnline(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, 30000); // Check every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: Home },
|
||||
{ name: 'Peers', href: '/peers', icon: Users },
|
||||
{ name: 'Network Services', href: '/network', icon: Network },
|
||||
{ name: 'WireGuard', href: '/wireguard', icon: Shield },
|
||||
{ name: 'Email', href: '/email', icon: Mail },
|
||||
{ name: 'Calendar', href: '/calendar', icon: CalendarIcon },
|
||||
{ name: 'Files', href: '/files', icon: FolderOpen },
|
||||
{ name: 'Routing', href: '/routing', icon: Wifi },
|
||||
{ name: 'Vault', href: '/vault', icon: Key },
|
||||
{ name: 'Containers', href: '/containers', icon: Package2 },
|
||||
{ name: 'Logs', href: '/logs', icon: Activity },
|
||||
{ name: 'Settings', href: '/settings', icon: SettingsIcon },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Connecting to Personal Internet Cell...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Sidebar navigation={navigation} isOnline={isOnline} />
|
||||
|
||||
<div className="lg:pl-72">
|
||||
<main className="py-10">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{!isOnline && (
|
||||
<div className="mb-6 bg-danger-50 border border-danger-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Server className="h-5 w-5 text-danger-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Backend Unavailable
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-danger-700">
|
||||
<p>
|
||||
Unable to connect to the Personal Internet Cell backend.
|
||||
Please ensure the API server is running on port 3000.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard isOnline={isOnline} />} />
|
||||
<Route path="/peers" element={<Peers />} />
|
||||
<Route path="/network" element={<NetworkServices />} />
|
||||
<Route path="/wireguard" element={<WireGuard />} />
|
||||
<Route path="/email" element={<Email />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/files" element={<Files />} />
|
||||
<Route path="/routing" element={<Routing />} />
|
||||
<Route path="/vault" element={<Vault />} />
|
||||
<Route path="/containers" element={<ContainerDashboard />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,328 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { containerAPI } from '../services/api';
|
||||
import { vaultAPI } from '../services/api';
|
||||
|
||||
const ContainerDashboard = () => {
|
||||
const [containers, setContainers] = useState([]);
|
||||
const [images, setImages] = useState([]);
|
||||
const [volumes, setVolumes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [logs, setLogs] = useState('');
|
||||
const [selectedContainer, setSelectedContainer] = useState(null);
|
||||
const [stats, setStats] = useState(null);
|
||||
// Form states
|
||||
const [newContainer, setNewContainer] = useState({ image: '', name: '', env: '', ports: '', volumes: '', command: '' });
|
||||
const [pullImageName, setPullImageName] = useState('');
|
||||
const [newVolumeName, setNewVolumeName] = useState('');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [secrets, setSecrets] = useState([]);
|
||||
const [newSecret, setNewSecret] = useState({ name: '', value: '' });
|
||||
const [selectedSecrets, setSelectedSecrets] = useState([]);
|
||||
|
||||
const fetchAll = async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const [cRes, iRes, vRes] = await Promise.all([
|
||||
containerAPI.listContainers(),
|
||||
containerAPI.listImages(),
|
||||
containerAPI.listVolumes(),
|
||||
]);
|
||||
setContainers(cRes.data);
|
||||
setImages(iRes.data);
|
||||
setVolumes(vRes.data);
|
||||
} catch (e) {
|
||||
setError('Failed to load data');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchSecrets = async () => {
|
||||
try {
|
||||
const res = await vaultAPI.listSecrets();
|
||||
setSecrets(res.data.secrets || []);
|
||||
} catch (e) {
|
||||
setError('Failed to load secrets');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
fetchSecrets();
|
||||
}, []);
|
||||
|
||||
const handleAction = async (action, name) => {
|
||||
setError('');
|
||||
setActionLoading(true);
|
||||
try {
|
||||
if (action === 'start') await containerAPI.startContainer(name);
|
||||
if (action === 'stop') await containerAPI.stopContainer(name);
|
||||
if (action === 'restart') await containerAPI.restartContainer(name);
|
||||
if (action === 'remove') await containerAPI.removeContainer(name);
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
setError(`Failed to ${action} container: ${name}`);
|
||||
}
|
||||
setActionLoading(false);
|
||||
};
|
||||
|
||||
const handleShowLogs = async (name) => {
|
||||
setError('');
|
||||
setLogs('Loading...');
|
||||
setSelectedContainer(name);
|
||||
try {
|
||||
const res = await containerAPI.getContainerLogs(name);
|
||||
setLogs(res.data.logs);
|
||||
} catch (e) {
|
||||
setLogs('Failed to load logs');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShowStats = async (name) => {
|
||||
setError('');
|
||||
setStats('Loading...');
|
||||
setSelectedContainer(name);
|
||||
try {
|
||||
const res = await containerAPI.getContainerStats(name);
|
||||
setStats(res.data);
|
||||
} catch (e) {
|
||||
setStats('Failed to load stats');
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSecret = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await vaultAPI.storeSecret(newSecret.name, newSecret.value);
|
||||
setNewSecret({ name: '', value: '' });
|
||||
fetchSecrets();
|
||||
} catch (e) {
|
||||
setError('Failed to add secret');
|
||||
}
|
||||
setActionLoading(false);
|
||||
};
|
||||
|
||||
const handleDeleteSecret = async (name) => {
|
||||
setError('');
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await vaultAPI.deleteSecret(name);
|
||||
fetchSecrets();
|
||||
} catch (e) {
|
||||
setError('Failed to delete secret');
|
||||
}
|
||||
setActionLoading(false);
|
||||
};
|
||||
|
||||
const handleSecretSelect = (e) => {
|
||||
const value = e.target.value;
|
||||
setSelectedSecrets(
|
||||
e.target.checked
|
||||
? [...selectedSecrets, value]
|
||||
: selectedSecrets.filter((s) => s !== value)
|
||||
);
|
||||
};
|
||||
|
||||
const handleCreateContainer = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setActionLoading(true);
|
||||
try {
|
||||
// Parse env, ports, volumes from string to object
|
||||
const env = newContainer.env ? Object.fromEntries(newContainer.env.split(',').map(pair => pair.split('='))) : {};
|
||||
const ports = newContainer.ports ? Object.fromEntries(newContainer.ports.split(',').map(pair => pair.split(':'))) : {};
|
||||
const volumes = newContainer.volumes ? Object.fromEntries(newContainer.volumes.split(',').map(pair => pair.split(':'))) : {};
|
||||
const data = {
|
||||
image: newContainer.image,
|
||||
name: newContainer.name,
|
||||
env,
|
||||
ports,
|
||||
volumes,
|
||||
command: newContainer.command,
|
||||
secrets: selectedSecrets
|
||||
};
|
||||
const res = await containerAPI.createContainer(data);
|
||||
if (res.data.error) setError(res.data.error);
|
||||
setNewContainer({ image: '', name: '', env: '', ports: '', volumes: '', command: '' });
|
||||
setSelectedSecrets([]);
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
setError('Failed to create container');
|
||||
}
|
||||
setActionLoading(false);
|
||||
};
|
||||
|
||||
const handlePullImage = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await containerAPI.pullImage(pullImageName);
|
||||
if (res.data.error) setError(res.data.error);
|
||||
setPullImageName('');
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
setError('Failed to pull image');
|
||||
}
|
||||
setActionLoading(false);
|
||||
};
|
||||
|
||||
const handleRemoveImage = async (image) => {
|
||||
setError('');
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await containerAPI.removeImage(image);
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
setError('Failed to remove image');
|
||||
}
|
||||
setActionLoading(false);
|
||||
};
|
||||
|
||||
const handleCreateVolume = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await containerAPI.createVolume(newVolumeName);
|
||||
if (res.data.error) setError(res.data.error);
|
||||
setNewVolumeName('');
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
setError('Failed to create volume');
|
||||
}
|
||||
setActionLoading(false);
|
||||
};
|
||||
|
||||
const handleRemoveVolume = async (name) => {
|
||||
setError('');
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await containerAPI.removeVolume(name);
|
||||
fetchAll();
|
||||
} catch (e) {
|
||||
setError('Failed to remove volume');
|
||||
}
|
||||
setActionLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<h2>Container Management Dashboard</h2>
|
||||
{loading ? <p>Loading...</p> : null}
|
||||
{error && <p style={{ color: 'red' }}>{error}</p>}
|
||||
<h3>Secrets</h3>
|
||||
<form onSubmit={handleAddSecret} style={{ marginBottom: 8 }}>
|
||||
<strong>Add Secret:</strong>
|
||||
<input required placeholder="Name" value={newSecret.name} onChange={e => setNewSecret({ ...newSecret, name: e.target.value })} />
|
||||
<input required placeholder="Value" value={newSecret.value} onChange={e => setNewSecret({ ...newSecret, value: e.target.value })} />
|
||||
<button type="submit" disabled={actionLoading}>Add</button>
|
||||
</form>
|
||||
<ul>
|
||||
{secrets.map((s) => (
|
||||
<li key={s}>
|
||||
{s}
|
||||
<button onClick={() => handleDeleteSecret(s)} disabled={actionLoading} style={{ marginLeft: 8 }}>Delete</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3>Containers</h3>
|
||||
<form onSubmit={handleCreateContainer} style={{ marginBottom: 16 }}>
|
||||
<strong>Create Container:</strong>
|
||||
<input required placeholder="Image" value={newContainer.image} onChange={e => setNewContainer({ ...newContainer, image: e.target.value })} />
|
||||
<input placeholder="Name" value={newContainer.name} onChange={e => setNewContainer({ ...newContainer, name: e.target.value })} />
|
||||
<input placeholder="Env (KEY=VAL,...)" value={newContainer.env} onChange={e => setNewContainer({ ...newContainer, env: e.target.value })} />
|
||||
<input placeholder="Ports (host:container,...)" value={newContainer.ports} onChange={e => setNewContainer({ ...newContainer, ports: e.target.value })} />
|
||||
<input placeholder="Volumes (host:container,...)" value={newContainer.volumes} onChange={e => setNewContainer({ ...newContainer, volumes: e.target.value })} />
|
||||
<input placeholder="Command" value={newContainer.command} onChange={e => setNewContainer({ ...newContainer, command: e.target.value })} />
|
||||
<div style={{ margin: '8px 0' }}>
|
||||
<strong>Attach Secrets:</strong>
|
||||
{secrets.map((s) => (
|
||||
<label key={s} style={{ marginLeft: 8 }}>
|
||||
<input type="checkbox" value={s} checked={selectedSecrets.includes(s)} onChange={handleSecretSelect} /> {s}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<button type="submit" disabled={actionLoading}>Create</button>
|
||||
</form>
|
||||
<table border="1" cellPadding="6">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Status</th>
|
||||
<th>Image</th>
|
||||
<th>Actions</th>
|
||||
<th>Logs</th>
|
||||
<th>Stats</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{containers.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.name}</td>
|
||||
<td>{c.status}</td>
|
||||
<td>{c.image && c.image.join(', ')}</td>
|
||||
<td>
|
||||
<button onClick={() => handleAction('start', c.name)} disabled={actionLoading}>Start</button>
|
||||
<button onClick={() => handleAction('stop', c.name)} disabled={actionLoading}>Stop</button>
|
||||
<button onClick={() => handleAction('restart', c.name)} disabled={actionLoading}>Restart</button>
|
||||
<button onClick={() => handleAction('remove', c.name)} disabled={actionLoading}>Remove</button>
|
||||
</td>
|
||||
<td>
|
||||
<button onClick={() => handleShowLogs(c.name)}>Show Logs</button>
|
||||
</td>
|
||||
<td>
|
||||
<button onClick={() => handleShowStats(c.name)}>Show Stats</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{selectedContainer && logs && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h4>Logs for {selectedContainer}</h4>
|
||||
<pre style={{ background: '#222', color: '#eee', padding: 12, maxHeight: 300, overflow: 'auto' }}>{logs}</pre>
|
||||
</div>
|
||||
)}
|
||||
{selectedContainer && stats && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<h4>Stats for {selectedContainer}</h4>
|
||||
<pre style={{ background: '#222', color: '#eee', padding: 12, maxHeight: 300, overflow: 'auto' }}>{typeof stats === 'string' ? stats : JSON.stringify(stats, null, 2) }</pre>
|
||||
</div>
|
||||
)}
|
||||
<h3>Images</h3>
|
||||
<form onSubmit={handlePullImage} style={{ marginBottom: 8 }}>
|
||||
<strong>Pull Image:</strong>
|
||||
<input required placeholder="Image name" value={pullImageName} onChange={e => setPullImageName(e.target.value)} />
|
||||
<button type="submit" disabled={actionLoading}>Pull</button>
|
||||
</form>
|
||||
<ul>
|
||||
{images.map((img) => (
|
||||
<li key={img.id}>
|
||||
{img.tags && img.tags.join(', ')} ({img.short_id})
|
||||
<button onClick={() => handleRemoveImage(img.id)} disabled={actionLoading} style={{ marginLeft: 8 }}>Remove</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3>Volumes</h3>
|
||||
<form onSubmit={handleCreateVolume} style={{ marginBottom: 8 }}>
|
||||
<strong>Create Volume:</strong>
|
||||
<input required placeholder="Volume name" value={newVolumeName} onChange={e => setNewVolumeName(e.target.value)} />
|
||||
<button type="submit" disabled={actionLoading}>Create</button>
|
||||
</form>
|
||||
<ul>
|
||||
{volumes.map((v) => (
|
||||
<li key={v.name}>
|
||||
{v.name} ({v.mountpoint})
|
||||
<button onClick={() => handleRemoveVolume(v.name)} disabled={actionLoading} style={{ marginLeft: 8 }}>Remove</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerDashboard;
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useState } from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
function Sidebar({ navigation, isOnline }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile sidebar */}
|
||||
<div className={clsx(
|
||||
'fixed inset-0 z-50 lg:hidden',
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}>
|
||||
<div className="fixed inset-0 bg-gray-900/80" onClick={() => setSidebarOpen(false)} />
|
||||
|
||||
<div className="fixed inset-y-0 left-0 z-50 w-72 bg-white">
|
||||
<div className="flex h-full flex-col gap-y-5 overflow-y-auto px-6 py-4">
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
Personal Internet Cell
|
||||
</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
to={item.href}
|
||||
className={clsx(
|
||||
location.pathname === item.href
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:text-primary-600 hover:bg-primary-50',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold'
|
||||
)}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon
|
||||
className={clsx(
|
||||
location.pathname === item.href ? 'text-primary-600' : 'text-gray-400 group-hover:text-primary-600',
|
||||
'h-6 w-6 shrink-0'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
|
||||
<div className="flex grow flex-col gap-y-5 overflow-y-auto border-r border-gray-200 bg-white px-6 pb-4">
|
||||
<div className="flex h-16 shrink-0 items-center">
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
Personal Internet Cell
|
||||
</h1>
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul role="list" className="flex flex-1 flex-col gap-y-7">
|
||||
<li>
|
||||
<ul role="list" className="-mx-2 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
to={item.href}
|
||||
className={clsx(
|
||||
location.pathname === item.href
|
||||
? 'bg-primary-50 text-primary-600'
|
||||
: 'text-gray-700 hover:text-primary-600 hover:bg-primary-50',
|
||||
'group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold'
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={clsx(
|
||||
location.pathname === item.href ? 'text-primary-600' : 'text-gray-400 group-hover:text-primary-600',
|
||||
'h-6 w-6 shrink-0'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
<li className="mt-auto">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className={clsx(
|
||||
'h-2 w-2 rounded-full',
|
||||
isOnline ? 'bg-success-500' : 'bg-danger-500'
|
||||
)} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{isOnline ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="sticky top-0 z-40 flex items-center gap-x-6 bg-white px-4 py-4 shadow-sm sm:px-6 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-gray-700 lg:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<span className="sr-only">Open sidebar</span>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex-1 text-sm font-semibold leading-6 text-gray-900">
|
||||
Personal Internet Cell
|
||||
</div>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<div className={clsx(
|
||||
'h-2 w-2 rounded-full',
|
||||
isOnline ? 'bg-success-500' : 'bg-danger-500'
|
||||
)} />
|
||||
<span className="text-xs text-gray-500">
|
||||
{isOnline ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-50 text-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-800 hover:bg-gray-300;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-danger-600 text-white hover:bg-danger-700;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-success-600 text-white hover:bg-success-700;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply bg-success-100 text-success-800;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply bg-danger-100 text-danger-800;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
@apply bg-warning-100 text-warning-800;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Calendar as CalendarIcon, Users, Clock } from 'lucide-react';
|
||||
import { calendarAPI } from '../services/api';
|
||||
|
||||
function Calendar() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCalendarData();
|
||||
}, []);
|
||||
|
||||
const fetchCalendarData = async () => {
|
||||
try {
|
||||
const [usersResponse, statusResponse] = await Promise.all([
|
||||
calendarAPI.getUsers(),
|
||||
calendarAPI.getStatus()
|
||||
]);
|
||||
|
||||
setUsers(usersResponse.data);
|
||||
setStatus(statusResponse.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch calendar data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Calendar Services</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage Radicale CalDAV and CardDAV services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Status */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<CalendarIcon className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Radicale:</span>
|
||||
<span className="text-sm font-medium text-success-600">Running</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">CalDAV:</span>
|
||||
<span className="text-sm font-medium text-success-600">Active</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">CardDAV:</span>
|
||||
<span className="text-sm font-medium text-success-600">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Status unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Users */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Users className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Calendar Users</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{users.length > 0 ? (
|
||||
users.map((user, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium">{user.username}</span>
|
||||
<span className="text-sm text-gray-500">{user.calendars || 0} calendars</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No calendar users configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Calendar;
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Server,
|
||||
Users,
|
||||
Shield,
|
||||
Mail,
|
||||
Calendar,
|
||||
FolderOpen,
|
||||
Wifi,
|
||||
Activity,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { cellAPI, servicesAPI } from '../services/api';
|
||||
|
||||
function Dashboard({ isOnline }) {
|
||||
const [cellStatus, setCellStatus] = useState(null);
|
||||
const [servicesStatus, setServicesStatus] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!isOnline) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const [statusResponse, servicesResponse] = await Promise.all([
|
||||
cellAPI.getStatus(),
|
||||
servicesAPI.getAllStatus()
|
||||
]);
|
||||
|
||||
setCellStatus(statusResponse.data);
|
||||
setServicesStatus(servicesResponse.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000); // Refresh every 30 seconds
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isOnline]);
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
if (status === true || status?.status === 'online' || status?.running === true) {
|
||||
return <CheckCircle className="h-5 w-5 text-success-500" />;
|
||||
} else if (status === false || status?.status === 'offline' || status?.running === false) {
|
||||
return <XCircle className="h-5 w-5 text-danger-500" />;
|
||||
} else {
|
||||
return <AlertCircle className="h-5 w-5 text-warning-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status) => {
|
||||
if (status === true || status?.status === 'online' || status?.running === true) {
|
||||
return 'Online';
|
||||
} else if (status === false || status?.status === 'offline' || status?.running === false) {
|
||||
return 'Offline';
|
||||
} else {
|
||||
return 'Unknown';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
if (status === true || status?.status === 'online' || status?.running === true) {
|
||||
return 'text-success-600';
|
||||
} else if (status === false || status?.status === 'offline' || status?.running === false) {
|
||||
return 'text-danger-600';
|
||||
} else {
|
||||
return 'text-warning-600';
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Overview of your Personal Internet Cell status and services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Cell Status */}
|
||||
{cellStatus && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Cell Status</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-8 w-8 text-primary-500" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Cell Name</p>
|
||||
<p className="text-lg font-semibold text-gray-900">{cellStatus.cell_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-8 w-8 text-primary-500" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Peers</p>
|
||||
<p className="text-lg font-semibold text-gray-900">{cellStatus.peers_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<Activity className="h-8 w-8 text-primary-500" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Uptime</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{Math.floor((cellStatus.uptime || 0) / 3600)}h {Math.floor(((cellStatus.uptime || 0) % 3600) / 60)}m
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<div className="h-3 w-3 rounded-full bg-primary-600"></div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Status</p>
|
||||
<p className="text-lg font-semibold text-gray-900">Active</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Status */}
|
||||
{servicesStatus && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Services Status</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-6 w-6 text-primary-500" />
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">WireGuard</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{getStatusIcon(servicesStatus.wireguard)}
|
||||
<span className={`ml-2 text-sm font-medium ${getStatusColor(servicesStatus.wireguard)}`}>
|
||||
{getStatusText(servicesStatus.wireguard)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Mail className="h-6 w-6 text-primary-500" />
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">Email</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{getStatusIcon(servicesStatus.email)}
|
||||
<span className={`ml-2 text-sm font-medium ${getStatusColor(servicesStatus.email)}`}>
|
||||
{getStatusText(servicesStatus.email)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="h-6 w-6 text-primary-500" />
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">Calendar</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{getStatusIcon(servicesStatus.calendar)}
|
||||
<span className={`ml-2 text-sm font-medium ${getStatusColor(servicesStatus.calendar)}`}>
|
||||
{getStatusText(servicesStatus.calendar)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<FolderOpen className="h-6 w-6 text-primary-500" />
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">Files</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{getStatusIcon(servicesStatus.files)}
|
||||
<span className={`ml-2 text-sm font-medium ${getStatusColor(servicesStatus.files)}`}>
|
||||
{getStatusText(servicesStatus.files)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Wifi className="h-6 w-6 text-primary-500" />
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">Routing</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{getStatusIcon(servicesStatus.routing)}
|
||||
<span className={`ml-2 text-sm font-medium ${getStatusColor(servicesStatus.routing)}`}>
|
||||
{getStatusText(servicesStatus.routing)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-6 w-6 text-primary-500" />
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">Network</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{getStatusIcon(servicesStatus.network)}
|
||||
<span className={`ml-2 text-sm font-medium ${getStatusColor(servicesStatus.network)}`}>
|
||||
{getStatusText(servicesStatus.network)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button className="card hover:shadow-md transition-shadow cursor-pointer">
|
||||
<div className="flex items-center">
|
||||
<Users className="h-6 w-6 text-primary-500" />
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">Manage Peers</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="card hover:shadow-md transition-shadow cursor-pointer">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-6 w-6 text-primary-500" />
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">WireGuard Config</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="card hover:shadow-md transition-shadow cursor-pointer">
|
||||
<div className="flex items-center">
|
||||
<Wifi className="h-6 w-6 text-primary-500" />
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">Routing Rules</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="card hover:shadow-md transition-shadow cursor-pointer">
|
||||
<div className="flex items-center">
|
||||
<Activity className="h-6 w-6 text-primary-500" />
|
||||
<span className="ml-3 text-sm font-medium text-gray-900">View Logs</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Mail, Users, Send } from 'lucide-react';
|
||||
import { emailAPI } from '../services/api';
|
||||
|
||||
function Email() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmailData();
|
||||
}, []);
|
||||
|
||||
const fetchEmailData = async () => {
|
||||
try {
|
||||
const [usersResponse, statusResponse] = await Promise.all([
|
||||
emailAPI.getUsers(),
|
||||
emailAPI.getStatus()
|
||||
]);
|
||||
|
||||
setUsers(usersResponse.data);
|
||||
setStatus(statusResponse.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch email data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Email Services</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage Postfix and Dovecot email services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Status */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Mail className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Postfix:</span>
|
||||
<span className="text-sm font-medium text-success-600">Running</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Dovecot:</span>
|
||||
<span className="text-sm font-medium text-success-600">Running</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Status unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Users */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Users className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Email Users</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{users.length > 0 ? (
|
||||
users.map((user, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium">{user.username}</span>
|
||||
<span className="text-sm text-gray-500">{user.domain}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No email users configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Email;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FolderOpen, Users, HardDrive } from 'lucide-react';
|
||||
import { fileAPI } from '../services/api';
|
||||
|
||||
function Files() {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFilesData();
|
||||
}, []);
|
||||
|
||||
const fetchFilesData = async () => {
|
||||
try {
|
||||
const [usersResponse, statusResponse] = await Promise.all([
|
||||
fileAPI.getUsers(),
|
||||
fileAPI.getStatus()
|
||||
]);
|
||||
|
||||
setUsers(usersResponse.data);
|
||||
setStatus(statusResponse.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch files data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">File Storage</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage WebDAV file storage services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Status */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<HardDrive className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Service Status</h3>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">WebDAV:</span>
|
||||
<span className="text-sm font-medium text-success-600">Running</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Storage:</span>
|
||||
<span className="text-sm font-medium text-success-600">Available</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Status unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Users */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Users className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Storage Users</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{users.length > 0 ? (
|
||||
users.map((user, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium">{user.username}</span>
|
||||
<span className="text-sm text-gray-500">{user.storage_used || '0'} MB</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No storage users configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Files;
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Activity, Clock, FileText, AlertTriangle } from 'lucide-react';
|
||||
import { monitoringAPI } from '../services/api';
|
||||
|
||||
function Logs() {
|
||||
const [backendLog, setBackendLog] = useState('');
|
||||
const [healthHistory, setHealthHistory] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [tab, setTab] = useState('logs');
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [logRes, healthRes] = await Promise.all([
|
||||
monitoringAPI.getBackendLogs(100),
|
||||
monitoringAPI.getHealthHistory(),
|
||||
]);
|
||||
setBackendLog(logRes.data.log || '');
|
||||
setHealthHistory(healthRes.data || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch monitoring data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">System Monitoring</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
View backend logs and health history
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex gap-4">
|
||||
<button
|
||||
className={`px-4 py-2 rounded ${tab === 'logs' ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-800'}`}
|
||||
onClick={() => setTab('logs')}
|
||||
>
|
||||
<FileText className="inline-block mr-2" /> Backend Logs
|
||||
</button>
|
||||
<button
|
||||
className={`px-4 py-2 rounded ${tab === 'health' ? 'bg-primary-600 text-white' : 'bg-gray-200 text-gray-800'}`}
|
||||
onClick={() => setTab('health')}
|
||||
>
|
||||
<Clock className="inline-block mr-2" /> Health History
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'logs' && (
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<FileText className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Backend Logs (last 100 lines)</h3>
|
||||
</div>
|
||||
<div className="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm h-96 overflow-y-auto">
|
||||
<pre>{backendLog || 'No logs available.'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'health' && (
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Clock className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Health History (last 100 checks)</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="px-2 py-1 text-left">Timestamp</th>
|
||||
<th className="px-2 py-1 text-left">Network</th>
|
||||
<th className="px-2 py-1 text-left">WireGuard</th>
|
||||
<th className="px-2 py-1 text-left">Email</th>
|
||||
<th className="px-2 py-1 text-left">Calendar</th>
|
||||
<th className="px-2 py-1 text-left">Files</th>
|
||||
<th className="px-2 py-1 text-left">Routing</th>
|
||||
<th className="px-2 py-1 text-left">Vault</th>
|
||||
<th className="px-2 py-1 text-left">Alerts</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{healthHistory.map((h, i) => (
|
||||
<tr key={i} className={h.alerts && h.alerts.length > 0 ? 'bg-red-100' : ''}>
|
||||
<td className="px-2 py-1 font-mono">{h.timestamp}</td>
|
||||
<td className="px-2 py-1">
|
||||
{h.network?.status === 'online' || h.network?.running === true ?
|
||||
<span className="text-green-600">OK</span> :
|
||||
<span className="text-red-600 font-bold">Down</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
{h.wireguard?.status === 'online' || h.wireguard?.running === true ?
|
||||
<span className="text-green-600">OK</span> :
|
||||
<span className="text-red-600 font-bold">Down</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
{h.email?.status === 'online' || h.email?.running === true ?
|
||||
<span className="text-green-600">OK</span> :
|
||||
<span className="text-red-600 font-bold">Down</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
{h.calendar?.status === 'online' || h.calendar?.running === true ?
|
||||
<span className="text-green-600">OK</span> :
|
||||
<span className="text-red-600 font-bold">Down</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
{h.files?.status === 'online' || h.files?.running === true ?
|
||||
<span className="text-green-600">OK</span> :
|
||||
<span className="text-red-600 font-bold">Down</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
{h.routing?.status === 'online' || h.routing?.running === true ?
|
||||
<span className="text-green-600">OK</span> :
|
||||
<span className="text-red-600 font-bold">Down</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
{h.vault?.status === 'online' || h.vault?.running === true ?
|
||||
<span className="text-green-600">OK</span> :
|
||||
<span className="text-red-600 font-bold">Down</span>
|
||||
}
|
||||
</td>
|
||||
<td className="px-2 py-1">
|
||||
{h.alerts && h.alerts.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{h.alerts.map((a, j) => (
|
||||
<span key={j} className="text-red-700 font-semibold flex items-center"><AlertTriangle className="inline-block h-4 w-4 mr-1 text-red-500" />{a}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-green-600">None</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Logs;
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Network, Server, Clock } from 'lucide-react';
|
||||
import { networkAPI } from '../services/api';
|
||||
|
||||
function NetworkServices() {
|
||||
const [dnsRecords, setDnsRecords] = useState([]);
|
||||
const [dhcpLeases, setDhcpLeases] = useState([]);
|
||||
const [ntpStatus, setNtpStatus] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchNetworkData();
|
||||
}, []);
|
||||
|
||||
const fetchNetworkData = async () => {
|
||||
try {
|
||||
const [dnsResponse, dhcpResponse, ntpResponse] = await Promise.all([
|
||||
networkAPI.getDNSRecords(),
|
||||
networkAPI.getDHCPLeases(),
|
||||
networkAPI.getNTPStatus()
|
||||
]);
|
||||
|
||||
setDnsRecords(dnsResponse.data);
|
||||
setDhcpLeases(dhcpResponse.data);
|
||||
setNtpStatus(ntpResponse.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch network data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Network Services</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage DNS, DHCP, and NTP services
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* DNS Records */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Network className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">DNS Records</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{dnsRecords.length > 0 ? (
|
||||
dnsRecords.map((record, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium">{record.name}</span>
|
||||
<span className="text-sm text-gray-500">{record.ip}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No DNS records configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DHCP Leases */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Server className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">DHCP Leases</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{dhcpLeases.length > 0 ? (
|
||||
dhcpLeases.map((lease, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium">{lease.hostname || 'Unknown'}</span>
|
||||
<span className="text-sm text-gray-500">{lease.ip}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No active DHCP leases</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NTP Status */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Clock className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">NTP Status</h3>
|
||||
</div>
|
||||
{ntpStatus ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Status:</span>
|
||||
<span className="text-sm font-medium text-success-600">Online</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Sync:</span>
|
||||
<span className="text-sm font-medium text-success-600">Synchronized</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">NTP service unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NetworkServices;
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, Edit, Eye, Wifi, Shield } from 'lucide-react';
|
||||
import { peerAPI } from '../services/api';
|
||||
|
||||
function Peers() {
|
||||
const [peers, setPeers] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [newPeer, setNewPeer] = useState({
|
||||
name: '',
|
||||
ip: '',
|
||||
public_key: '',
|
||||
allowed_ips: '',
|
||||
description: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchPeers();
|
||||
}, []);
|
||||
|
||||
const fetchPeers = async () => {
|
||||
try {
|
||||
const response = await peerAPI.getPeers();
|
||||
setPeers(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch peers:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPeer = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await peerAPI.addPeer(newPeer);
|
||||
setShowAddModal(false);
|
||||
setNewPeer({ name: '', ip: '', public_key: '', allowed_ips: '', description: '' });
|
||||
fetchPeers();
|
||||
} catch (error) {
|
||||
console.error('Failed to add peer:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemovePeer = async (peerName) => {
|
||||
if (window.confirm(`Are you sure you want to remove peer "${peerName}"?`)) {
|
||||
try {
|
||||
await peerAPI.removePeer(peerName);
|
||||
fetchPeers();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove peer:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Peers</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage peer connections and WireGuard configurations
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="btn btn-primary flex items-center"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Peer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Peers List */}
|
||||
<div className="card">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
IP Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Type
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{peers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="5" className="px-6 py-4 text-center text-gray-500">
|
||||
No peers configured. Add your first peer to get started.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
peers.map((peer) => (
|
||||
<tr key={peer.name} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{peer.name}</div>
|
||||
{peer.description && (
|
||||
<div className="text-sm text-gray-500">{peer.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{peer.ip}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="status-indicator status-online">
|
||||
Online
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-4 w-4 text-primary-500 mr-2" />
|
||||
<span className="text-sm text-gray-900">WireGuard</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
className="text-primary-600 hover:text-primary-900"
|
||||
title="View Details"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
className="text-primary-600 hover:text-primary-900"
|
||||
title="Edit Peer"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemovePeer(peer.name)}
|
||||
className="text-danger-600 hover:text-danger-900"
|
||||
title="Remove Peer"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Peer Modal */}
|
||||
{showAddModal && (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Add New Peer</h3>
|
||||
<form onSubmit={handleAddPeer}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Peer Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newPeer.name}
|
||||
onChange={(e) => setNewPeer({ ...newPeer, name: e.target.value })}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
IP Address
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newPeer.ip}
|
||||
onChange={(e) => setNewPeer({ ...newPeer, ip: e.target.value })}
|
||||
className="input"
|
||||
placeholder="10.0.0.1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Public Key
|
||||
</label>
|
||||
<textarea
|
||||
value={newPeer.public_key}
|
||||
onChange={(e) => setNewPeer({ ...newPeer, public_key: e.target.value })}
|
||||
className="input"
|
||||
rows="3"
|
||||
placeholder="Enter WireGuard public key"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Allowed IPs
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newPeer.allowed_ips}
|
||||
onChange={(e) => setNewPeer({ ...newPeer, allowed_ips: e.target.value })}
|
||||
className="input"
|
||||
placeholder="192.168.1.0/24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newPeer.description}
|
||||
onChange={(e) => setNewPeer({ ...newPeer, description: e.target.value })}
|
||||
className="input"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(false)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Add Peer
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Peers;
|
||||
@@ -0,0 +1,707 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, Wifi, Shield, Activity, Settings } from 'lucide-react';
|
||||
import { routingAPI } from '../services/api';
|
||||
|
||||
function Routing() {
|
||||
const [routingStatus, setRoutingStatus] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
// NAT management state
|
||||
const [natRules, setNatRules] = useState([]);
|
||||
const [natLoading, setNatLoading] = useState(false);
|
||||
const [natError, setNatError] = useState(null);
|
||||
const [showNatForm, setShowNatForm] = useState(false);
|
||||
const [newNat, setNewNat] = useState({ source_network: '', target_interface: '', masquerade: true, nat_type: 'MASQUERADE', protocol: 'ALL', external_port: '', internal_ip: '', internal_port: '' });
|
||||
const [natSubmitting, setNatSubmitting] = useState(false);
|
||||
// Peer Routes management state
|
||||
const [peerRoutes, setPeerRoutes] = useState([]);
|
||||
const [peersLoading, setPeersLoading] = useState(false);
|
||||
const [peersError, setPeersError] = useState(null);
|
||||
const [showPeerForm, setShowPeerForm] = useState(false);
|
||||
const [newPeerRoute, setNewPeerRoute] = useState({ peer_name: '', peer_ip: '', allowed_networks: '', route_type: 'lan' });
|
||||
const [peerSubmitting, setPeerSubmitting] = useState(false);
|
||||
// Firewall Rules management state
|
||||
const [firewallRules, setFirewallRules] = useState([]);
|
||||
const [fwLoading, setFwLoading] = useState(false);
|
||||
const [fwError, setFwError] = useState(null);
|
||||
const [showFwForm, setShowFwForm] = useState(false);
|
||||
const [newFwRule, setNewFwRule] = useState({ rule_type: 'INPUT', source: '', destination: '', action: 'ACCEPT', protocol: 'ALL', port_range: '' });
|
||||
const [fwSubmitting, setFwSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoutingStatus();
|
||||
fetchNatRules();
|
||||
fetchPeerRoutes();
|
||||
fetchFirewallRules();
|
||||
}, []);
|
||||
|
||||
const fetchRoutingStatus = async () => {
|
||||
try {
|
||||
const response = await routingAPI.getStatus();
|
||||
setRoutingStatus(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch routing status:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchNatRules = async () => {
|
||||
setNatLoading(true);
|
||||
setNatError(null);
|
||||
try {
|
||||
const response = await routingAPI.getNatRules();
|
||||
setNatRules(response.data.nat_rules || []);
|
||||
} catch (error) {
|
||||
setNatError('Failed to load NAT rules');
|
||||
} finally {
|
||||
setNatLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPeerRoutes = async () => {
|
||||
setPeersLoading(true);
|
||||
setPeersError(null);
|
||||
try {
|
||||
const response = await routingAPI.getPeerRoutes();
|
||||
setPeerRoutes(response.data.peer_routes || []);
|
||||
} catch (error) {
|
||||
setPeersError('Failed to load peer routes');
|
||||
} finally {
|
||||
setPeersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFirewallRules = async () => {
|
||||
setFwLoading(true);
|
||||
setFwError(null);
|
||||
try {
|
||||
const response = await routingAPI.getFirewallRules();
|
||||
setFirewallRules(response.data.firewall_rules || []);
|
||||
} catch (error) {
|
||||
setFwError('Failed to load firewall rules');
|
||||
} finally {
|
||||
setFwLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNatInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setNewNat((prev) => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddNatRule = async (e) => {
|
||||
e.preventDefault();
|
||||
setNatSubmitting(true);
|
||||
setNatError(null);
|
||||
try {
|
||||
await routingAPI.addNatRule(newNat);
|
||||
setShowNatForm(false);
|
||||
setNewNat({ source_network: '', target_interface: '', masquerade: true, nat_type: 'MASQUERADE', protocol: 'ALL', external_port: '', internal_ip: '', internal_port: '' });
|
||||
fetchNatRules();
|
||||
fetchRoutingStatus();
|
||||
} catch (error) {
|
||||
setNatError('Failed to add NAT rule');
|
||||
} finally {
|
||||
setNatSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteNatRule = async (ruleId) => {
|
||||
if (!window.confirm('Delete this NAT rule?')) return;
|
||||
setNatLoading(true);
|
||||
setNatError(null);
|
||||
try {
|
||||
await routingAPI.deleteNatRule(ruleId);
|
||||
fetchNatRules();
|
||||
fetchRoutingStatus();
|
||||
} catch (error) {
|
||||
setNatError('Failed to delete NAT rule');
|
||||
} finally {
|
||||
setNatLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePeerInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setNewPeerRoute((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleAddPeerRoute = async (e) => {
|
||||
e.preventDefault();
|
||||
setPeerSubmitting(true);
|
||||
setPeersError(null);
|
||||
try {
|
||||
// allowed_networks: comma-separated string to array
|
||||
const payload = {
|
||||
...newPeerRoute,
|
||||
allowed_networks: newPeerRoute.allowed_networks.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
};
|
||||
await routingAPI.addPeerRoute(payload);
|
||||
setShowPeerForm(false);
|
||||
setNewPeerRoute({ peer_name: '', peer_ip: '', allowed_networks: '', route_type: 'lan' });
|
||||
fetchPeerRoutes();
|
||||
fetchRoutingStatus();
|
||||
} catch (error) {
|
||||
setPeersError('Failed to add peer route');
|
||||
} finally {
|
||||
setPeerSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePeerRoute = async (peerName) => {
|
||||
if (!window.confirm('Delete this peer route?')) return;
|
||||
setPeersLoading(true);
|
||||
setPeersError(null);
|
||||
try {
|
||||
await routingAPI.deletePeerRoute(peerName);
|
||||
fetchPeerRoutes();
|
||||
fetchRoutingStatus();
|
||||
} catch (error) {
|
||||
setPeersError('Failed to delete peer route');
|
||||
} finally {
|
||||
setPeersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFwInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setNewFwRule((prev) => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleAddFwRule = async (e) => {
|
||||
e.preventDefault();
|
||||
setFwSubmitting(true);
|
||||
setFwError(null);
|
||||
try {
|
||||
const payload = { ...newFwRule };
|
||||
if (!payload.port) delete payload.port;
|
||||
await routingAPI.addFirewallRule(payload);
|
||||
setShowFwForm(false);
|
||||
setNewFwRule({ rule_type: 'INPUT', source: '', destination: '', action: 'ACCEPT', protocol: 'ALL', port_range: '' });
|
||||
fetchFirewallRules();
|
||||
fetchRoutingStatus();
|
||||
} catch (error) {
|
||||
setFwError('Failed to add firewall rule');
|
||||
} finally {
|
||||
setFwSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFwRule = async (ruleId) => {
|
||||
if (!window.confirm('Delete this firewall rule?')) return;
|
||||
setFwLoading(true);
|
||||
setFwError(null);
|
||||
try {
|
||||
await routingAPI.deleteFirewallRule(ruleId);
|
||||
fetchFirewallRules();
|
||||
fetchRoutingStatus();
|
||||
} catch (error) {
|
||||
setFwError('Failed to delete firewall rule');
|
||||
} finally {
|
||||
setFwLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', name: 'Overview', icon: Activity },
|
||||
{ id: 'nat', name: 'NAT Rules', icon: Shield },
|
||||
{ id: 'peers', name: 'Peer Routes', icon: Wifi },
|
||||
{ id: 'firewall', name: 'Firewall', icon: Settings },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Routing & Gateway</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage VPN gateway, NAT rules, and routing configuration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Overview */}
|
||||
{routingStatus && (
|
||||
<div className="mb-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-8 w-8 text-primary-500" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">NAT Rules</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{routingStatus.nat_rules_count || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<Wifi className="h-8 w-8 text-primary-500" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Peer Routes</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{routingStatus.peer_routes_count || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<Settings className="h-8 w-8 text-primary-500" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Firewall Rules</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{routingStatus.firewall_rules_count || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<Activity className="h-8 w-8 text-primary-500" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Exit Nodes</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{routingStatus.exit_nodes_count || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6">
|
||||
<nav className="flex space-x-8">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`flex items-center px-1 py-2 border-b-2 font-medium text-sm ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 mr-2" />
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="card">
|
||||
{activeTab === 'overview' && (
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Routing Overview</h3>
|
||||
{routingStatus?.routing_table && routingStatus.routing_table.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{routingStatus.routing_table.map((route, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center">
|
||||
<Wifi className="h-4 w-4 text-primary-500 mr-2" />
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{route.route}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{route.parsed?.via && `via ${route.parsed.via}`}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500">No routing table entries available.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'nat' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">NAT Rules</h3>
|
||||
<button className="btn btn-primary flex items-center" onClick={() => setShowNatForm((v) => !v)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{showNatForm ? 'Cancel' : 'Add NAT Rule'}
|
||||
</button>
|
||||
</div>
|
||||
{showNatForm && (
|
||||
<form className="mb-4 p-4 bg-gray-50 rounded-lg" onSubmit={handleAddNatRule}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
name="source_network"
|
||||
placeholder="Source Network (e.g. 192.168.1.0/24)"
|
||||
className="input"
|
||||
value={newNat.source_network}
|
||||
onChange={handleNatInputChange}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="target_interface"
|
||||
placeholder="Target Interface (e.g. eth0)"
|
||||
className="input"
|
||||
value={newNat.target_interface}
|
||||
onChange={handleNatInputChange}
|
||||
required
|
||||
/>
|
||||
<label className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="masquerade"
|
||||
checked={newNat.masquerade}
|
||||
onChange={handleNatInputChange}
|
||||
/>
|
||||
<span>Masquerade</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-4">
|
||||
<select
|
||||
name="nat_type"
|
||||
className="input"
|
||||
value={newNat.nat_type}
|
||||
onChange={handleNatInputChange}
|
||||
>
|
||||
<option value="MASQUERADE">MASQUERADE</option>
|
||||
<option value="SNAT">SNAT</option>
|
||||
<option value="DNAT">DNAT (Port Forward)</option>
|
||||
</select>
|
||||
<select
|
||||
name="protocol"
|
||||
className="input"
|
||||
value={newNat.protocol}
|
||||
onChange={handleNatInputChange}
|
||||
>
|
||||
<option value="ALL">ALL</option>
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="UDP">UDP</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
name="external_port"
|
||||
placeholder="External Port (for DNAT)"
|
||||
className="input"
|
||||
value={newNat.external_port}
|
||||
onChange={handleNatInputChange}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="internal_ip"
|
||||
placeholder="Internal IP (for DNAT)"
|
||||
className="input"
|
||||
value={newNat.internal_ip}
|
||||
onChange={handleNatInputChange}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="internal_port"
|
||||
placeholder="Internal Port (for DNAT)"
|
||||
className="input"
|
||||
value={newNat.internal_port}
|
||||
onChange={handleNatInputChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
<span>Advanced: Use DNAT for port forwarding, specify protocol/ports as needed.</span>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="submit" className="btn btn-primary" disabled={natSubmitting}>
|
||||
{natSubmitting ? 'Adding...' : 'Add Rule'}
|
||||
</button>
|
||||
</div>
|
||||
{natError && <p className="text-red-500 mt-2">{natError}</p>}
|
||||
</form>
|
||||
)}
|
||||
{natLoading ? (
|
||||
<div className="py-8 text-center text-gray-500">Loading NAT rules...</div>
|
||||
) : natError ? (
|
||||
<div className="py-8 text-center text-red-500">{natError}</div>
|
||||
) : natRules.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">No NAT rules configured.</div>
|
||||
) : (
|
||||
<table className="min-w-full bg-white rounded-lg overflow-hidden">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left">Source Network</th>
|
||||
<th className="px-4 py-2 text-left">Target Interface</th>
|
||||
<th className="px-4 py-2 text-left">Masquerade</th>
|
||||
<th className="px-4 py-2 text-left">Type</th>
|
||||
<th className="px-4 py-2 text-left">Protocol</th>
|
||||
<th className="px-4 py-2 text-left">Ext Port</th>
|
||||
<th className="px-4 py-2 text-left">Int IP</th>
|
||||
<th className="px-4 py-2 text-left">Int Port</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{natRules.map((rule, idx) => (
|
||||
<tr key={rule.id || idx} className="border-t">
|
||||
<td className="px-4 py-2">{rule.source_network}</td>
|
||||
<td className="px-4 py-2">{rule.target_interface}</td>
|
||||
<td className="px-4 py-2">{rule.masquerade ? 'Yes' : 'No'}</td>
|
||||
<td className="px-4 py-2">{rule.nat_type || 'MASQUERADE'}</td>
|
||||
<td className="px-4 py-2">{rule.protocol || 'ALL'}</td>
|
||||
<td className="px-4 py-2">{rule.external_port || '-'}</td>
|
||||
<td className="px-4 py-2">{rule.internal_ip || '-'}</td>
|
||||
<td className="px-4 py-2">{rule.internal_port || '-'}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
className="btn btn-danger btn-sm flex items-center"
|
||||
onClick={() => handleDeleteNatRule(rule.id || idx)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" /> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'peers' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Peer Routes</h3>
|
||||
<button className="btn btn-primary flex items-center" onClick={() => setShowPeerForm((v) => !v)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{showPeerForm ? 'Cancel' : 'Add Peer Route'}
|
||||
</button>
|
||||
</div>
|
||||
{showPeerForm && (
|
||||
<form className="mb-4 p-4 bg-gray-50 rounded-lg" onSubmit={handleAddPeerRoute}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<input
|
||||
type="text"
|
||||
name="peer_name"
|
||||
placeholder="Peer Name"
|
||||
className="input"
|
||||
value={newPeerRoute.peer_name}
|
||||
onChange={handlePeerInputChange}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="peer_ip"
|
||||
placeholder="Peer IP (e.g. 10.0.0.2)"
|
||||
className="input"
|
||||
value={newPeerRoute.peer_ip}
|
||||
onChange={handlePeerInputChange}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="allowed_networks"
|
||||
placeholder="Allowed Networks (comma-separated)"
|
||||
className="input"
|
||||
value={newPeerRoute.allowed_networks}
|
||||
onChange={handlePeerInputChange}
|
||||
/>
|
||||
<select
|
||||
name="route_type"
|
||||
className="input"
|
||||
value={newPeerRoute.route_type}
|
||||
onChange={handlePeerInputChange}
|
||||
>
|
||||
<option value="lan">LAN</option>
|
||||
<option value="exit">Exit</option>
|
||||
<option value="bridge">Bridge</option>
|
||||
<option value="split">Split</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="submit" className="btn btn-primary" disabled={peerSubmitting}>
|
||||
{peerSubmitting ? 'Adding...' : 'Add Route'}
|
||||
</button>
|
||||
</div>
|
||||
{peersError && <p className="text-red-500 mt-2">{peersError}</p>}
|
||||
</form>
|
||||
)}
|
||||
{peersLoading ? (
|
||||
<div className="py-8 text-center text-gray-500">Loading peer routes...</div>
|
||||
) : peersError ? (
|
||||
<div className="py-8 text-center text-red-500">{peersError}</div>
|
||||
) : peerRoutes.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">No peer routes configured.</div>
|
||||
) : (
|
||||
<table className="min-w-full bg-white rounded-lg overflow-hidden">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left">Peer Name</th>
|
||||
<th className="px-4 py-2 text-left">Peer IP</th>
|
||||
<th className="px-4 py-2 text-left">Allowed Networks</th>
|
||||
<th className="px-4 py-2 text-left">Route Type</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{peerRoutes.map((route, idx) => (
|
||||
<tr key={route.peer_name || idx} className="border-t">
|
||||
<td className="px-4 py-2">{route.peer_name}</td>
|
||||
<td className="px-4 py-2">{route.peer_ip}</td>
|
||||
<td className="px-4 py-2">{Array.isArray(route.allowed_networks) ? route.allowed_networks.join(', ') : route.allowed_networks}</td>
|
||||
<td className="px-4 py-2">{route.route_type}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
className="btn btn-danger btn-sm flex items-center"
|
||||
onClick={() => handleDeletePeerRoute(route.peer_name)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" /> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'firewall' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Firewall Rules</h3>
|
||||
<button className="btn btn-primary flex items-center" onClick={() => setShowFwForm((v) => !v)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{showFwForm ? 'Cancel' : 'Add Firewall Rule'}
|
||||
</button>
|
||||
</div>
|
||||
{showFwForm && (
|
||||
<form className="mb-4 p-4 bg-gray-50 rounded-lg" onSubmit={handleAddFwRule}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
|
||||
<select
|
||||
name="rule_type"
|
||||
className="input"
|
||||
value={newFwRule.rule_type}
|
||||
onChange={handleFwInputChange}
|
||||
>
|
||||
<option value="INPUT">INPUT</option>
|
||||
<option value="OUTPUT">OUTPUT</option>
|
||||
<option value="FORWARD">FORWARD</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
name="source"
|
||||
placeholder="Source (e.g. 192.168.1.0/24)"
|
||||
className="input"
|
||||
value={newFwRule.source}
|
||||
onChange={handleFwInputChange}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="destination"
|
||||
placeholder="Destination (e.g. 0.0.0.0/0)"
|
||||
className="input"
|
||||
value={newFwRule.destination}
|
||||
onChange={handleFwInputChange}
|
||||
required
|
||||
/>
|
||||
<select
|
||||
name="protocol"
|
||||
className="input"
|
||||
value={newFwRule.protocol}
|
||||
onChange={handleFwInputChange}
|
||||
>
|
||||
<option value="ALL">ALL</option>
|
||||
<option value="TCP">TCP</option>
|
||||
<option value="UDP">UDP</option>
|
||||
<option value="ICMP">ICMP</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
name="port_range"
|
||||
placeholder="Port or Range (e.g. 80 or 1000-2000)"
|
||||
className="input"
|
||||
value={newFwRule.port_range}
|
||||
onChange={handleFwInputChange}
|
||||
/>
|
||||
<select
|
||||
name="action"
|
||||
className="input"
|
||||
value={newFwRule.action}
|
||||
onChange={handleFwInputChange}
|
||||
>
|
||||
<option value="ACCEPT">ACCEPT</option>
|
||||
<option value="DROP">DROP</option>
|
||||
<option value="REJECT">REJECT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-2">
|
||||
<span>Advanced: Specify protocol and port/range for granular matching.</span>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button type="submit" className="btn btn-primary" disabled={fwSubmitting}>
|
||||
{fwSubmitting ? 'Adding...' : 'Add Rule'}
|
||||
</button>
|
||||
</div>
|
||||
{fwError && <p className="text-red-500 mt-2">{fwError}</p>}
|
||||
</form>
|
||||
)}
|
||||
{fwLoading ? (
|
||||
<div className="py-8 text-center text-gray-500">Loading firewall rules...</div>
|
||||
) : fwError ? (
|
||||
<div className="py-8 text-center text-red-500">{fwError}</div>
|
||||
) : firewallRules.length === 0 ? (
|
||||
<div className="py-8 text-center text-gray-500">No firewall rules configured.</div>
|
||||
) : (
|
||||
<table className="min-w-full bg-white rounded-lg overflow-hidden">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left">Rule Type</th>
|
||||
<th className="px-4 py-2 text-left">Source</th>
|
||||
<th className="px-4 py-2 text-left">Destination</th>
|
||||
<th className="px-4 py-2 text-left">Protocol</th>
|
||||
<th className="px-4 py-2 text-left">Port/Range</th>
|
||||
<th className="px-4 py-2 text-left">Action</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{firewallRules.map((rule, idx) => (
|
||||
<tr key={rule.id || idx} className="border-t">
|
||||
<td className="px-4 py-2">{rule.rule_type}</td>
|
||||
<td className="px-4 py-2">{rule.source}</td>
|
||||
<td className="px-4 py-2">{rule.destination}</td>
|
||||
<td className="px-4 py-2">{rule.protocol || 'ALL'}</td>
|
||||
<td className="px-4 py-2">{rule.port_range || '-'}</td>
|
||||
<td className="px-4 py-2">{rule.action}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<button
|
||||
className="btn btn-danger btn-sm flex items-center"
|
||||
onClick={() => handleDeleteFwRule(rule.id || idx)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" /> Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Routing;
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Settings as SettingsIcon, Server, Shield } from 'lucide-react';
|
||||
import { cellAPI } from '../services/api';
|
||||
|
||||
function Settings() {
|
||||
const [config, setConfig] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const fetchConfig = async () => {
|
||||
try {
|
||||
const response = await cellAPI.getConfig();
|
||||
setConfig(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Configure your Personal Internet Cell
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Cell Configuration */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Server className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Cell Configuration</h3>
|
||||
</div>
|
||||
{config ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Cell Name:</span>
|
||||
<span className="text-sm font-medium">{config.cell_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Domain:</span>
|
||||
<span className="text-sm font-medium">{config.domain}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">IP Range:</span>
|
||||
<span className="text-sm font-medium">{config.ip_range}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">WireGuard Port:</span>
|
||||
<span className="text-sm font-medium">{config.wireguard_port}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Configuration unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Settings */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Shield className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Security Settings</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">TLS Certificate:</span>
|
||||
<span className="text-sm font-medium text-success-600">Valid</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Firewall:</span>
|
||||
<span className="text-sm font-medium text-success-600">Active</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">VPN Encryption:</span>
|
||||
<span className="text-sm font-medium text-success-600">Enabled</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Settings;
|
||||
@@ -0,0 +1,451 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Shield, Key, Users, Plus, Trash2, Download } from 'lucide-react';
|
||||
import { vaultAPI } from '../services/api';
|
||||
|
||||
function Vault() {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [certificates, setCertificates] = useState([]);
|
||||
const [trustedKeys, setTrustedKeys] = useState({});
|
||||
const [trustChains, setTrustChains] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showAddCertModal, setShowAddCertModal] = useState(false);
|
||||
const [showAddKeyModal, setShowAddKeyModal] = useState(false);
|
||||
const [newCert, setNewCert] = useState({
|
||||
common_name: '',
|
||||
domains: '',
|
||||
key_size: 2048,
|
||||
days: 365
|
||||
});
|
||||
const [newKey, setNewKey] = useState({
|
||||
name: '',
|
||||
public_key: '',
|
||||
trust_level: 'direct'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchVaultData();
|
||||
}, []);
|
||||
|
||||
const fetchVaultData = async () => {
|
||||
try {
|
||||
const [statusResponse, certsResponse, keysResponse, chainsResponse] = await Promise.all([
|
||||
vaultAPI.getStatus(),
|
||||
vaultAPI.getCertificates(),
|
||||
vaultAPI.getTrustedKeys(),
|
||||
vaultAPI.getTrustChains()
|
||||
]);
|
||||
|
||||
setStatus(statusResponse.data);
|
||||
setCertificates(certsResponse.data);
|
||||
setTrustedKeys(keysResponse.data);
|
||||
setTrustChains(chainsResponse.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch vault data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerateCertificate = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
const certData = {
|
||||
...newCert,
|
||||
domains: newCert.domains ? newCert.domains.split(',').map(d => d.trim()) : []
|
||||
};
|
||||
|
||||
await vaultAPI.generateCertificate(certData);
|
||||
setShowAddCertModal(false);
|
||||
setNewCert({ common_name: '', domains: '', key_size: 2048, days: 365 });
|
||||
fetchVaultData();
|
||||
} catch (error) {
|
||||
console.error('Failed to generate certificate:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTrustedKey = async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await vaultAPI.addTrustedKey(newKey);
|
||||
setShowAddKeyModal(false);
|
||||
setNewKey({ name: '', public_key: '', trust_level: 'direct' });
|
||||
fetchVaultData();
|
||||
} catch (error) {
|
||||
console.error('Failed to add trusted key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeCertificate = async (commonName) => {
|
||||
if (window.confirm(`Are you sure you want to revoke certificate "${commonName}"?`)) {
|
||||
try {
|
||||
await vaultAPI.revokeCertificate(commonName);
|
||||
fetchVaultData();
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke certificate:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTrustedKey = async (name) => {
|
||||
if (window.confirm(`Are you sure you want to remove trusted key "${name}"?`)) {
|
||||
try {
|
||||
await vaultAPI.removeTrustedKey(name);
|
||||
fetchVaultData();
|
||||
} catch (error) {
|
||||
console.error('Failed to remove trusted key:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
return new Date(dateString).toLocaleDateString();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Vault & Trust</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage certificates, trust systems, and security settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Overview */}
|
||||
{status && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold text-gray-900 mb-4">Vault Status</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<Key className="h-8 w-8 text-primary-500" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Certificates</p>
|
||||
<p className="text-lg font-semibold text-gray-900">{status.certificates_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<Key className="h-8 w-8 text-primary-500" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Trusted Keys</p>
|
||||
<p className="text-lg font-semibold text-gray-900">{status.trusted_keys_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-8 w-8 text-primary-500" />
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">Trust Chains</p>
|
||||
<p className="text-lg font-semibold text-gray-900">{status.trust_chains_count}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-center">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<div className="h-3 w-3 rounded-full bg-primary-600"></div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-gray-500">CA Status</p>
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{status.ca_configured ? 'Active' : 'Inactive'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Certificates */}
|
||||
<div className="card">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<Key className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Certificates</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddCertModal(true)}
|
||||
className="btn btn-primary flex items-center"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{certificates.length > 0 ? (
|
||||
certificates.map((cert, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{cert.common_name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Valid until {formatDate(cert.not_valid_after)}
|
||||
{cert.expired && <span className="text-danger-600 ml-2">(Expired)</span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{cert.encrypted && (
|
||||
<span className="status-indicator status-online">Encrypted</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleRevokeCertificate(cert.common_name)}
|
||||
className="text-danger-600 hover:text-danger-900"
|
||||
title="Revoke Certificate"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No certificates generated</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trusted Keys */}
|
||||
<div className="card">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<Key className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Trusted Keys</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAddKeyModal(true)}
|
||||
className="btn btn-primary flex items-center"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{Object.keys(trustedKeys).length > 0 ? (
|
||||
Object.entries(trustedKeys).map(([name, key]) => (
|
||||
<div key={name} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{name}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{key.public_key.substring(0, 20)}...
|
||||
<span className={`ml-2 status-indicator ${key.verified ? 'status-online' : 'status-warning'}`}>
|
||||
{key.trust_level}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleRemoveTrustedKey(name)}
|
||||
className="text-danger-600 hover:text-danger-900"
|
||||
title="Remove Key"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No trusted keys configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Trust Chains */}
|
||||
{Object.keys(trustChains).length > 0 && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Trust Chains</h3>
|
||||
<div className="card">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(trustChains).map(([peer, chain]) => (
|
||||
<div key={peer} className="flex justify-between items-center p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">{peer}</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Verified: {formatDate(chain.verified_at)}
|
||||
</div>
|
||||
</div>
|
||||
<span className={`status-indicator ${chain.trust_level === 'direct' ? 'status-online' : 'status-warning'}`}>
|
||||
{chain.trust_level}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate Certificate Modal */}
|
||||
{showAddCertModal && (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Generate Certificate</h3>
|
||||
<form onSubmit={handleGenerateCertificate}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Common Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newCert.common_name}
|
||||
onChange={(e) => setNewCert({ ...newCert, common_name: e.target.value })}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Domains (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newCert.domains}
|
||||
onChange={(e) => setNewCert({ ...newCert, domains: e.target.value })}
|
||||
className="input"
|
||||
placeholder="example.com, www.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Key Size
|
||||
</label>
|
||||
<select
|
||||
value={newCert.key_size}
|
||||
onChange={(e) => setNewCert({ ...newCert, key_size: parseInt(e.target.value) })}
|
||||
className="input"
|
||||
>
|
||||
<option value={2048}>2048 bits</option>
|
||||
<option value={4096}>4096 bits</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Validity (days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newCert.days}
|
||||
onChange={(e) => setNewCert({ ...newCert, days: parseInt(e.target.value) })}
|
||||
className="input"
|
||||
min="1"
|
||||
max="3650"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddCertModal(false)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Trusted Key Modal */}
|
||||
{showAddKeyModal && (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Add Trusted Key</h3>
|
||||
<form onSubmit={handleAddTrustedKey}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newKey.name}
|
||||
onChange={(e) => setNewKey({ ...newKey, name: e.target.value })}
|
||||
className="input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Public Key
|
||||
</label>
|
||||
<textarea
|
||||
value={newKey.public_key}
|
||||
onChange={(e) => setNewKey({ ...newKey, public_key: e.target.value })}
|
||||
className="input"
|
||||
rows="3"
|
||||
placeholder="age1..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Trust Level
|
||||
</label>
|
||||
<select
|
||||
value={newKey.trust_level}
|
||||
onChange={(e) => setNewKey({ ...newKey, trust_level: e.target.value })}
|
||||
className="input"
|
||||
>
|
||||
<option value="direct">Direct</option>
|
||||
<option value="indirect">Indirect</option>
|
||||
<option value="verified">Verified</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddKeyModal(false)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Add Key
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Vault;
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Shield, Key, Users } from 'lucide-react';
|
||||
import { wireguardAPI } from '../services/api';
|
||||
|
||||
function WireGuard() {
|
||||
const [status, setStatus] = useState(null);
|
||||
const [peers, setPeers] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWireGuardData();
|
||||
}, []);
|
||||
|
||||
const fetchWireGuardData = async () => {
|
||||
try {
|
||||
const [statusResponse, peersResponse] = await Promise.all([
|
||||
wireguardAPI.getStatus(),
|
||||
wireguardAPI.getPeers()
|
||||
]);
|
||||
|
||||
setStatus(statusResponse.data);
|
||||
setPeers(peersResponse.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch WireGuard data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900">WireGuard</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Manage WireGuard VPN configuration and peers
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Status */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Shield className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Status</h3>
|
||||
</div>
|
||||
{status ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Interface:</span>
|
||||
<span className="text-sm font-medium">{status.interface || 'wg0'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-500">Status:</span>
|
||||
<span className="text-sm font-medium text-success-600">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">Status unavailable</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Peers */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<Users className="h-6 w-6 text-primary-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Peers</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{peers.length > 0 ? (
|
||||
peers.map((peer, index) => (
|
||||
<div key={index} className="flex justify-between items-center p-2 bg-gray-50 rounded">
|
||||
<span className="text-sm font-medium">{peer.name}</span>
|
||||
<span className="text-sm text-gray-500">{peer.ip}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">No peers configured</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WireGuard;
|
||||
@@ -0,0 +1,205 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Create axios instance with base configuration
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor for logging
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`API Request: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('API Request Error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('API Response Error:', error.response?.data || error.message);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Cell Status API
|
||||
export const cellAPI = {
|
||||
getStatus: () => api.get('/api/status'),
|
||||
getConfig: () => api.get('/api/config'),
|
||||
updateConfig: (config) => api.put('/api/config', config),
|
||||
};
|
||||
|
||||
// Network Services API
|
||||
export const networkAPI = {
|
||||
getDNSRecords: () => api.get('/api/dns/records'),
|
||||
addDNSRecord: (record) => api.post('/api/dns/records', record),
|
||||
removeDNSRecord: (record) => api.delete('/api/dns/records', { data: record }),
|
||||
getDHCPLeases: () => api.get('/api/dhcp/leases'),
|
||||
addDHCPReservation: (reservation) => api.post('/api/dhcp/reservations', reservation),
|
||||
removeDHCPReservation: (reservation) => api.delete('/api/dhcp/reservations', { data: reservation }),
|
||||
getNTPStatus: () => api.get('/api/ntp/status'),
|
||||
testNetwork: (data) => api.post('/api/network/test', data),
|
||||
};
|
||||
|
||||
// WireGuard API
|
||||
export const wireguardAPI = {
|
||||
getKeys: () => api.get('/api/wireguard/keys'),
|
||||
generatePeerKeys: (data) => api.post('/api/wireguard/keys/peer', data),
|
||||
getConfig: () => api.get('/api/wireguard/config'),
|
||||
getPeers: () => api.get('/api/wireguard/peers'),
|
||||
addPeer: (peer) => api.post('/api/wireguard/peers', peer),
|
||||
removePeer: (peer) => api.delete('/api/wireguard/peers', { data: peer }),
|
||||
getStatus: () => api.get('/api/wireguard/status'),
|
||||
testConnectivity: (data) => api.post('/api/wireguard/connectivity', data),
|
||||
updatePeerIP: (data) => api.put('/api/wireguard/peers/ip', data),
|
||||
getPeerConfig: (data) => api.post('/api/wireguard/peers/config', data),
|
||||
};
|
||||
|
||||
// Peer Registry API
|
||||
export const peerAPI = {
|
||||
getPeers: () => api.get('/api/peers'),
|
||||
addPeer: (peer) => api.post('/api/peers', peer),
|
||||
removePeer: (peerName) => api.delete(`/api/peers/${peerName}`),
|
||||
registerPeer: (data) => api.post('/api/peers/register', data),
|
||||
unregisterPeer: (peerName) => api.delete(`/api/peers/${peerName}/unregister`),
|
||||
updatePeerIP: (peerName, data) => api.put(`/api/peers/${peerName}/update-ip`, data),
|
||||
};
|
||||
|
||||
// Email Services API
|
||||
export const emailAPI = {
|
||||
getUsers: () => api.get('/api/email/users'),
|
||||
createUser: (user) => api.post('/api/email/users', user),
|
||||
deleteUser: (username) => api.delete(`/api/email/users/${username}`),
|
||||
getStatus: () => api.get('/api/email/status'),
|
||||
testConnectivity: () => api.get('/api/email/connectivity'),
|
||||
sendEmail: (data) => api.post('/api/email/send', data),
|
||||
getMailboxInfo: (username) => api.get(`/api/email/mailbox/${username}`),
|
||||
};
|
||||
|
||||
// Calendar Services API
|
||||
export const calendarAPI = {
|
||||
getUsers: () => api.get('/api/calendar/users'),
|
||||
createUser: (user) => api.post('/api/calendar/users', user),
|
||||
deleteUser: (username) => api.delete(`/api/calendar/users/${username}`),
|
||||
createCalendar: (data) => api.post('/api/calendar/calendars', data),
|
||||
addEvent: (data) => api.post('/api/calendar/events', data),
|
||||
getEvents: (username, calendarName, params) =>
|
||||
api.get(`/api/calendar/events/${username}/${calendarName}`, { params }),
|
||||
getStatus: () => api.get('/api/calendar/status'),
|
||||
testConnectivity: () => api.get('/api/calendar/connectivity'),
|
||||
};
|
||||
|
||||
// File Services API
|
||||
export const fileAPI = {
|
||||
getUsers: () => api.get('/api/files/users'),
|
||||
createUser: (user) => api.post('/api/files/users', user),
|
||||
deleteUser: (username) => api.delete(`/api/files/users/${username}`),
|
||||
createFolder: (data) => api.post('/api/files/folders', data),
|
||||
deleteFolder: (username, folderPath) => api.delete(`/api/files/folders/${username}/${folderPath}`),
|
||||
uploadFile: (username, file, path) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('path', path);
|
||||
return api.post(`/api/files/upload/${username}`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
downloadFile: (username, filePath) => api.get(`/api/files/download/${username}/${filePath}`),
|
||||
deleteFile: (username, filePath) => api.delete(`/api/files/delete/${username}/${filePath}`),
|
||||
listFiles: (username, folder = '') => api.get(`/api/files/list/${username}`, { params: { folder } }),
|
||||
getStatus: () => api.get('/api/files/status'),
|
||||
testConnectivity: () => api.get('/api/files/connectivity'),
|
||||
};
|
||||
|
||||
// Routing API
|
||||
export const routingAPI = {
|
||||
getStatus: () => api.get('/api/routing/status'),
|
||||
// NAT
|
||||
getNatRules: () => api.get('/api/routing/nat'),
|
||||
addNatRule: (rule) => api.post('/api/routing/nat', rule),
|
||||
deleteNatRule: (ruleId) => api.delete(`/api/routing/nat/${ruleId}`),
|
||||
// Peer Routes
|
||||
getPeerRoutes: () => api.get('/api/routing/peers'),
|
||||
addPeerRoute: (route) => api.post('/api/routing/peers', route),
|
||||
deletePeerRoute: (peerName) => api.delete(`/api/routing/peers/${peerName}`),
|
||||
// Firewall
|
||||
getFirewallRules: () => api.get('/api/routing/firewall'),
|
||||
addFirewallRule: (rule) => api.post('/api/routing/firewall', rule),
|
||||
deleteFirewallRule: (ruleId) => api.delete(`/api/routing/firewall/${ruleId}`),
|
||||
// Other
|
||||
addExitNode: (node) => api.post('/api/routing/exit-nodes', node),
|
||||
addBridgeRoute: (route) => api.post('/api/routing/bridge', route),
|
||||
addSplitRoute: (route) => api.post('/api/routing/split', route),
|
||||
testConnectivity: (data) => api.post('/api/routing/connectivity', data),
|
||||
getLogs: (lines = 50) => api.get('/api/routing/logs', { params: { lines } }),
|
||||
};
|
||||
|
||||
// Vault & Trust API
|
||||
export const vaultAPI = {
|
||||
getStatus: () => api.get('/api/vault/status'),
|
||||
getCertificates: () => api.get('/api/vault/certificates'),
|
||||
generateCertificate: (data) => api.post('/api/vault/certificates', data),
|
||||
revokeCertificate: (commonName) => api.delete(`/api/vault/certificates/${commonName}`),
|
||||
getCACertificate: () => api.get('/api/vault/ca/certificate'),
|
||||
getAgePublicKey: () => api.get('/api/vault/age/public-key'),
|
||||
getTrustedKeys: () => api.get('/api/vault/trust/keys'),
|
||||
addTrustedKey: (data) => api.post('/api/vault/trust/keys', data),
|
||||
removeTrustedKey: (name) => api.delete(`/api/vault/trust/keys/${name}`),
|
||||
verifyTrustChain: (data) => api.post('/api/vault/trust/verify', data),
|
||||
getTrustChains: () => api.get('/api/vault/trust/chains'),
|
||||
// Secrets management
|
||||
listSecrets: () => api.get('/api/vault/secrets'),
|
||||
storeSecret: (name, value) => api.post('/api/vault/secrets', { name, value }),
|
||||
getSecret: (name) => api.get(`/api/vault/secrets/${name}`),
|
||||
deleteSecret: (name) => api.delete(`/api/vault/secrets/${name}`),
|
||||
};
|
||||
|
||||
// Services API
|
||||
export const servicesAPI = {
|
||||
getAllStatus: () => api.get('/api/services/status'),
|
||||
testAllConnectivity: () => api.get('/api/services/connectivity'),
|
||||
};
|
||||
|
||||
// Health check
|
||||
export const healthAPI = {
|
||||
check: () => api.get('/health'),
|
||||
};
|
||||
|
||||
// Monitoring API
|
||||
export const monitoringAPI = {
|
||||
getBackendLogs: (lines = 100) => api.get('/api/logs', { params: { lines } }),
|
||||
getHealthHistory: () => api.get('/api/health/history'),
|
||||
};
|
||||
|
||||
// Container Management API
|
||||
export const containerAPI = {
|
||||
// Containers
|
||||
listContainers: () => api.get('/api/containers'),
|
||||
startContainer: (name) => api.post(`/api/containers/${name}/start`),
|
||||
stopContainer: (name) => api.post(`/api/containers/${name}/stop`),
|
||||
restartContainer: (name) => api.post(`/api/containers/${name}/restart`),
|
||||
getContainerLogs: (name, tail = 100) => api.get(`/api/containers/${name}/logs`, { params: { tail } }),
|
||||
getContainerStats: (name) => api.get(`/api/containers/${name}/stats`),
|
||||
createContainer: (data) => api.post('/api/containers', data), // data may include 'secrets' array
|
||||
removeContainer: (name, force = false) => api.delete(`/api/containers/${name}`, { params: { force } }),
|
||||
// Images
|
||||
listImages: () => api.get('/api/images'),
|
||||
pullImage: (image) => api.post('/api/images/pull', { image }),
|
||||
removeImage: (image, force = false) => api.delete(`/api/images/${image}`, { params: { force } }),
|
||||
// Volumes
|
||||
listVolumes: () => api.get('/api/volumes'),
|
||||
createVolume: (name) => api.post('/api/volumes', { name }),
|
||||
removeVolume: (name, force = false) => api.delete(`/api/volumes/${name}`, { params: { force } }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,62 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/health': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user