Initial commit
This commit is contained in:
10
frontend/.dockerignore
Executable file
10
frontend/.dockerignore
Executable file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.md
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
41
frontend/.eslintrc.json
Executable file
41
frontend/.eslintrc.json
Executable file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"root": true,
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "detect"
|
||||
}
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2020": true,
|
||||
"node": true
|
||||
},
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:react-hooks/recommended"
|
||||
],
|
||||
"ignorePatterns": ["dist", ".eslintrc.cjs"],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
}
|
||||
},
|
||||
"plugins": ["react-refresh"],
|
||||
"rules": {
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ "allowConstantExport": true }
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"react/prop-types": "off",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"no-console": ["warn", { "allow": ["warn", "error"] }]
|
||||
}
|
||||
}
|
||||
10
frontend/.prettierrc.json
Executable file
10
frontend/.prettierrc.json
Executable file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"semi": false,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
35
frontend/Dockerfile
Executable file
35
frontend/Dockerfile
Executable file
@@ -0,0 +1,35 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy build artifacts
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Create logs directory
|
||||
RUN mkdir -p /var/log/nginx
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/ || exit 1
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
16
frontend/index.html
Executable file
16
frontend/index.html
Executable file
@@ -0,0 +1,16 @@
|
||||
<!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>SAP Business One ↔ Plesk Sync</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
79
frontend/nginx.conf
Executable file
79
frontend/nginx.conf
Executable file
@@ -0,0 +1,79 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
|
||||
gzip_disable "MSIE [1-6].";
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# API proxy
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3001/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_connect_timeout 75s;
|
||||
}
|
||||
|
||||
# Static files with correct MIME types
|
||||
location ~* \.(js|json)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Content-Type application/javascript;
|
||||
}
|
||||
|
||||
location ~* \.(css)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
add_header Content-Type text/css;
|
||||
}
|
||||
|
||||
location ~* \.(png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# SPA fallback - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
6123
frontend/package-lock.json
generated
Normal file
6123
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
frontend/package.json
Normal file
42
frontend/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "sap-sync-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 27"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.11.0",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@mui/icons-material": "^5.14.0",
|
||||
"@mui/material": "^5.14.0",
|
||||
"@mui/x-date-pickers": "^7.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"dayjs": "^1.11.0",
|
||||
"qrcode.react": "^3.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-diff-viewer-continued": "^3.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"react-router-dom": "^6.20.0",
|
||||
"recharts": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.0",
|
||||
"@typescript-eslint/parser": "^6.13.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.26",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Executable file
1
frontend/public/vite.svg
Executable file
@@ -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="#FFBD4F"></stop><stop offset="100%" stop-color="#FF980E"></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.704c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
292
frontend/src/App.tsx
Executable file
292
frontend/src/App.tsx
Executable file
@@ -0,0 +1,292 @@
|
||||
import { BrowserRouter, Route, Routes, Navigate } from 'react-router-dom';
|
||||
import { ThemeProvider, createTheme, CssBaseline } from '@mui/material';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import SyncPage from './pages/SyncPage';
|
||||
import ReportsPage from './pages/ReportsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import SetupWizardPage from './pages/SetupWizardPage';
|
||||
import SyncSimulationPage from './pages/SyncSimulationPage';
|
||||
import ConflictsPage from './pages/ConflictsPage';
|
||||
import BillingPage from './pages/BillingPage';
|
||||
import AlertsPage from './pages/AlertsPage';
|
||||
import ServersPage from './pages/ServersPage';
|
||||
import AuditPage from './pages/AuditPage';
|
||||
import Layout from './components/Layout';
|
||||
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext';
|
||||
import { I18nProvider } from './contexts/I18nContext';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#6366f1',
|
||||
light: '#818cf8',
|
||||
dark: '#4f46e5',
|
||||
},
|
||||
secondary: {
|
||||
main: '#ec4899',
|
||||
light: '#f472b6',
|
||||
dark: '#db2777',
|
||||
},
|
||||
success: {
|
||||
main: '#10b981',
|
||||
light: '#34d399',
|
||||
},
|
||||
warning: {
|
||||
main: '#f59e0b',
|
||||
light: '#fbbf24',
|
||||
},
|
||||
error: {
|
||||
main: '#ef4444',
|
||||
light: '#f87171',
|
||||
},
|
||||
background: {
|
||||
default: '#f8fafc',
|
||||
paper: '#ffffff',
|
||||
},
|
||||
text: {
|
||||
primary: '#1e293b',
|
||||
secondary: '#64748b',
|
||||
},
|
||||
grey: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: '"Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
h1: {
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
},
|
||||
h2: {
|
||||
fontWeight: 700,
|
||||
letterSpacing: '-0.02em',
|
||||
},
|
||||
h3: {
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
h4: {
|
||||
fontWeight: 600,
|
||||
letterSpacing: '-0.01em',
|
||||
},
|
||||
h5: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
h6: {
|
||||
fontWeight: 600,
|
||||
},
|
||||
subtitle1: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
subtitle2: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
body1: {
|
||||
lineHeight: 1.6,
|
||||
},
|
||||
body2: {
|
||||
lineHeight: 1.5,
|
||||
},
|
||||
button: {
|
||||
fontWeight: 600,
|
||||
textTransform: 'none',
|
||||
},
|
||||
},
|
||||
shape: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
shadows: [
|
||||
'none',
|
||||
'0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||
'0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
'0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||
'0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
'0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
'0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||
],
|
||||
components: {
|
||||
MuiCard: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 16,
|
||||
border: '1px solid',
|
||||
borderColor: '#e2e8f0',
|
||||
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
'&:hover': {
|
||||
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiButton: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 10,
|
||||
padding: '10px 20px',
|
||||
fontSize: '0.875rem',
|
||||
},
|
||||
contained: {
|
||||
boxShadow: 'none',
|
||||
'&:hover': {
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
},
|
||||
containedPrimary: {
|
||||
background: 'linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiChip: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 8,
|
||||
fontWeight: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
elevation1: {
|
||||
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiTextField: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 10,
|
||||
'&:hover .MuiOutlinedInput-notchedOutline': {
|
||||
borderColor: '#6366f1',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiSelect: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
borderRadius: 10,
|
||||
},
|
||||
},
|
||||
},
|
||||
MuiAppBar: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
boxShadow: '0 1px 3px 0 rgb(0 0 0 / 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
<Route path="sync" element={<SyncPage />} />
|
||||
<Route path="simulation" element={<SyncSimulationPage />} />
|
||||
<Route path="conflicts" element={<ConflictsPage />} />
|
||||
<Route path="billing" element={<BillingPage />} />
|
||||
<Route path="alerts" element={<AlertsPage />} />
|
||||
<Route path="servers" element={<ServersPage />} />
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="setup" element={<SetupWizardPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<I18nProvider>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<AppRoutes />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
borderRadius: 12,
|
||||
background: '#1e293b',
|
||||
color: '#f8fafc',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
99
frontend/src/components/ErrorBoundary.tsx
Normal file
99
frontend/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Box, Typography, Button, Paper } from '@mui/material';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
errorInfo: ErrorInfo | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
public state: State = {
|
||||
hasError: false,
|
||||
error: null,
|
||||
errorInfo: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error, errorInfo: null };
|
||||
}
|
||||
|
||||
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
this.setState({ error, errorInfo });
|
||||
}
|
||||
|
||||
public render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f8fafc',
|
||||
padding: 3,
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={3}
|
||||
sx={{
|
||||
maxWidth: 600,
|
||||
width: '100%',
|
||||
padding: 4,
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h4" gutterBottom color="error" sx={{ fontWeight: 700 }}>
|
||||
Something went wrong
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary" sx={{ mb: 3 }}>
|
||||
The application encountered an unexpected error.
|
||||
</Typography>
|
||||
|
||||
{this.state.error && (
|
||||
<Box
|
||||
sx={{
|
||||
background: '#f1f5f9',
|
||||
borderRadius: 2,
|
||||
padding: 2,
|
||||
mb: 3,
|
||||
overflow: 'auto',
|
||||
maxHeight: 200,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" component="pre" sx={{ fontFamily: 'monospace', fontSize: '0.8rem', whiteSpace: 'pre-wrap' }}>
|
||||
{this.state.error.toString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
Reload Page
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => window.location.href = '/login'}
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
181
frontend/src/components/Layout.tsx
Executable file
181
frontend/src/components/Layout.tsx
Executable file
@@ -0,0 +1,181 @@
|
||||
import React from 'react';
|
||||
import { Outlet, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
Divider,
|
||||
Drawer,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Menu as MenuIcon,
|
||||
Dashboard as DashboardIcon,
|
||||
Sync as SyncIcon,
|
||||
Settings as SettingsIcon,
|
||||
Report as ReportIcon,
|
||||
ExitToApp as LogoutIcon,
|
||||
CompareArrows as CompareIcon,
|
||||
Warning as WarningIcon,
|
||||
Receipt as ReceiptIcon,
|
||||
Notifications as NotificationsIcon,
|
||||
Dns as ServerIcon,
|
||||
History as HistoryIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
const Layout: React.FC = () => {
|
||||
const [mobileOpen, setMobileOpen] = React.useState(false);
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
const { t, language, changeLanguage } = useI18n();
|
||||
|
||||
const menuItems = [
|
||||
{ text: t('dashboard.title'), icon: <DashboardIcon />, path: '/dashboard' },
|
||||
{ text: t('nav.sync'), icon: <SyncIcon />, path: '/sync' },
|
||||
{ text: t('nav.conflicts'), icon: <WarningIcon />, path: '/conflicts' },
|
||||
{ text: t('nav.billing'), icon: <ReceiptIcon />, path: '/billing' },
|
||||
{ text: t('nav.alerts'), icon: <NotificationsIcon />, path: '/alerts' },
|
||||
{ text: t('nav.servers'), icon: <ServerIcon />, path: '/servers' },
|
||||
{ text: t('nav.audit'), icon: <HistoryIcon />, path: '/audit' },
|
||||
{ text: t('nav.simulation'), icon: <CompareIcon />, path: '/simulation' },
|
||||
{ text: t('nav.reports'), icon: <ReportIcon />, path: '/reports' },
|
||||
{ text: t('nav.settings'), icon: <SettingsIcon />, path: '/settings' },
|
||||
];
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
navigate('/login', { replace: true });
|
||||
};
|
||||
|
||||
const drawer = (
|
||||
<div>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" noWrap component="div">
|
||||
SAP Sync
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
<Divider />
|
||||
<List>
|
||||
{menuItems.map((item) => (
|
||||
<ListItem key={item.path} disablePadding>
|
||||
<ListItemButton onClick={() => navigate(item.path)}>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.text} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<ListItem disablePadding>
|
||||
<ListItemButton onClick={handleLogout}>
|
||||
<ListItemIcon>
|
||||
<LogoutIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('nav.logout')} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<AppBar
|
||||
position="fixed"
|
||||
sx={{
|
||||
width: { sm: `calc(100% - ${drawerWidth}px)` },
|
||||
ml: { sm: `${drawerWidth}px` },
|
||||
}}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
edge="start"
|
||||
onClick={handleDrawerToggle}
|
||||
sx={{ mr: 2, display: { sm: 'none' } }}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||
{t('app.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ mr: 2 }}>
|
||||
{user?.username}
|
||||
</Typography>
|
||||
<FormControl sx={{ minWidth: 120 }}>
|
||||
<Select
|
||||
value={language}
|
||||
onChange={(e) => changeLanguage(e.target.value)}
|
||||
displayEmpty
|
||||
variant="standard"
|
||||
sx={{ color: 'white' }}
|
||||
>
|
||||
<MenuItem value="de">DE</MenuItem>
|
||||
<MenuItem value="en">EN</MenuItem>
|
||||
<MenuItem value="fr">FR</MenuItem>
|
||||
<MenuItem value="es">ES</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Box
|
||||
component="nav"
|
||||
sx={{ width: { sm: drawerWidth }, flexShrink: { sm: 0 } }}
|
||||
aria-label="mailbox folders"
|
||||
>
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={handleDrawerToggle}
|
||||
ModalProps={{
|
||||
keepMounted: true,
|
||||
}}
|
||||
sx={{
|
||||
display: { xs: 'block', sm: 'none' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
display: { xs: 'none', sm: 'block' },
|
||||
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: drawerWidth },
|
||||
}}
|
||||
open
|
||||
>
|
||||
{drawer}
|
||||
</Drawer>
|
||||
</Box>
|
||||
<Box
|
||||
component="main"
|
||||
sx={{ flexGrow: 1, p: 3, width: { sm: `calc(100% - ${drawerWidth}px)` } }}
|
||||
>
|
||||
<Toolbar />
|
||||
<Outlet />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
186
frontend/src/components/ScheduleBuilder.tsx
Executable file
186
frontend/src/components/ScheduleBuilder.tsx
Executable file
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
Paper,
|
||||
Alert,
|
||||
Divider,
|
||||
} from '@mui/material';
|
||||
|
||||
export const ScheduleBuilderDialog: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (schedule: { type: string; config: Record<string, unknown> }) => void;
|
||||
}> = ({ open, onClose, onSave }) => {
|
||||
const [scheduleType, setScheduleType] = useState<'daily' | 'weekly' | 'monthly' | 'custom'>('daily');
|
||||
const [config, setConfig] = useState({
|
||||
hour: 2,
|
||||
weekday: 0,
|
||||
day: 1,
|
||||
});
|
||||
|
||||
const scheduleTypes = [
|
||||
{ value: 'daily', label: 'Daily', description: 'Run every day at a specific time' },
|
||||
{ value: 'weekly', label: 'Weekly', description: 'Run every week on a specific day' },
|
||||
{ value: 'monthly', label: 'Monthly', description: 'Run every month on a specific day' },
|
||||
{ value: 'custom', label: 'Custom', description: 'Define your own schedule (cron expression)' },
|
||||
];
|
||||
|
||||
const handleSave = () => {
|
||||
const scheduleConfig: Record<string, unknown> = { type: scheduleType };
|
||||
|
||||
if (scheduleType !== 'custom') {
|
||||
scheduleConfig.hour = config.hour;
|
||||
if (scheduleType === 'weekly') {
|
||||
scheduleConfig.weekday = config.weekday;
|
||||
scheduleConfig.dayName = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][config.weekday];
|
||||
}
|
||||
if (scheduleType === 'monthly') {
|
||||
scheduleConfig.day = config.day;
|
||||
}
|
||||
}
|
||||
|
||||
onSave({ type: scheduleType, config: scheduleConfig });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create Scheduled Sync</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>Select Schedule Type</Typography>
|
||||
|
||||
<Grid container spacing={1} sx={{ mb: 3 }}>
|
||||
{scheduleTypes.map((type) => (
|
||||
<Grid item xs={12} sm={6} key={type.value}>
|
||||
<Paper
|
||||
onClick={() => setScheduleType(type.value as 'daily' | 'weekly' | 'monthly' | 'custom')}
|
||||
sx={{
|
||||
p: 2,
|
||||
cursor: 'pointer',
|
||||
border: scheduleType === type.value ? '2px solid' : '1px solid',
|
||||
borderColor: scheduleType === type.value ? 'primary.main' : 'grey.300',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
>
|
||||
<Typography variant="subtitle2" fontWeight={600}>
|
||||
{type.label}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{type.description}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
{scheduleType !== 'custom' && (
|
||||
<Box>
|
||||
<Typography variant="subtitle2" gutterBottom>Schedule Configuration</Typography>
|
||||
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" gutterBottom>What time should the sync run?</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1">
|
||||
At {config.hour.toString().padStart(2, '0')}:00
|
||||
</Typography>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="23"
|
||||
value={config.hour}
|
||||
onChange={(e) => setConfig({ ...config, hour: parseInt(e.target.value) })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{scheduleType === 'weekly' && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" gutterBottom>Which day of the week?</Typography>
|
||||
<Grid container spacing={1}>
|
||||
{['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].map((day, index) => (
|
||||
<Grid item xs={6} sm={3} key={day}>
|
||||
<Button
|
||||
variant={config.weekday === index ? 'contained' : 'outlined'}
|
||||
fullWidth
|
||||
size="small"
|
||||
onClick={() => setConfig({ ...config, weekday: index })}
|
||||
>
|
||||
{day}
|
||||
</Button>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{scheduleType === 'monthly' && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Typography variant="caption" gutterBottom>Which day of the month?</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="subtitle1">
|
||||
On day {config.day}
|
||||
</Typography>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="31"
|
||||
value={config.day}
|
||||
onChange={(e) => setConfig({ ...config, day: parseInt(e.target.value) })}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
<strong>Schedule Summary:</strong>
|
||||
</Typography>
|
||||
<Alert severity="info" sx={{ mt: 1 }}>
|
||||
Run {scheduleType === 'daily' && 'daily'}
|
||||
{scheduleType === 'weekly' && `every ${['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][config.weekday]}`}
|
||||
{scheduleType === 'monthly' && `on day ${config.day} of each month`}
|
||||
{' '}at {config.hour.toString().padStart(2, '0')}:00
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{scheduleType === 'custom' && (
|
||||
<Alert severity="warning">
|
||||
Custom schedules (cron expressions) are not yet supported. Please use the predefined schedule types.
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={scheduleType === 'custom'}>
|
||||
Create Schedule
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// This component integrates into the existing SyncPage
|
||||
export const ScheduleBuilder = () => {
|
||||
return (
|
||||
<Box sx={{ p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>
|
||||
Scheduled Syncs
|
||||
</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Create automated sync schedules
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
115
frontend/src/contexts/AuthContext.tsx
Executable file
115
frontend/src/contexts/AuthContext.tsx
Executable file
@@ -0,0 +1,115 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { apiFetch, getErrorMessage } from '../lib/api';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean;
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
logger.debug('Checking authentication...');
|
||||
try {
|
||||
const response = await apiFetch('/auth/me', { method: 'GET' });
|
||||
|
||||
if (response.ok) {
|
||||
const userData = await response.json();
|
||||
logger.debug('User authenticated:', userData.username);
|
||||
setUser(userData);
|
||||
setIsAuthenticated(true);
|
||||
} else {
|
||||
logger.debug('Not authenticated:', response.status);
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Auth check failed:', error);
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
logger.debug('Auth check complete');
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const response = await apiFetch('/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await getErrorMessage(response, 'Login failed'));
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setUser(data.user);
|
||||
setIsAuthenticated(true);
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
await apiFetch('/auth/logout', {
|
||||
method: 'POST',
|
||||
});
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '100vh',
|
||||
backgroundColor: '#f8fafc',
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
<CircularProgress size={48} />
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
Loading...
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated, user, loading, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
1187
frontend/src/contexts/I18nContext.tsx
Executable file
1187
frontend/src/contexts/I18nContext.tsx
Executable file
File diff suppressed because it is too large
Load Diff
25
frontend/src/index.css
Executable file
25
frontend/src/index.css
Executable file
@@ -0,0 +1,25 @@
|
||||
:root {
|
||||
font-family: 'Inter', system-ui, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
background-color: #f5f5f5;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
362
frontend/src/lib/api.ts
Executable file
362
frontend/src/lib/api.ts
Executable file
@@ -0,0 +1,362 @@
|
||||
const API_BASE = (import.meta.env.VITE_API_URL as string | undefined)?.replace(/\/$/, '') || '/api'
|
||||
|
||||
export function apiUrl(path: string): string {
|
||||
if (/^https?:\/\//.test(path)) return path
|
||||
return `${API_BASE}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (init.body && typeof init.body === 'string') {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
return fetch(apiUrl(path), {
|
||||
credentials: 'include',
|
||||
...init,
|
||||
headers: {
|
||||
...headers,
|
||||
...init.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function getErrorMessage(response: Response, fallback: string): Promise<string> {
|
||||
try {
|
||||
const data = await response.json()
|
||||
return data?.error ?? data?.message ?? fallback
|
||||
} catch {
|
||||
try {
|
||||
const text = await response.text()
|
||||
return text || fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiJson<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await apiFetch(path, init)
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData?.error || errorData?.message || 'Request failed')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
role: string
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
user: User
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface SyncJob {
|
||||
id: number
|
||||
job_type: string
|
||||
sync_direction: string
|
||||
status: string
|
||||
records_processed: number
|
||||
records_failed: number
|
||||
created_at: string
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
is_running: boolean
|
||||
stats: {
|
||||
running: number
|
||||
completed_today: number
|
||||
failed_today: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SyncJobsResponse {
|
||||
jobs: SyncJob[]
|
||||
}
|
||||
|
||||
export interface Conflict {
|
||||
id: number
|
||||
sync_job_id: number
|
||||
entity_type: string
|
||||
entity_id: string
|
||||
resolution_status: string
|
||||
source_data: Record<string, unknown>
|
||||
conflict_details: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface PricingConfig {
|
||||
id: number
|
||||
metric_type: string
|
||||
unit: string
|
||||
rate_per_unit: number
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export interface BillingRecord {
|
||||
id: number
|
||||
customer_id: number
|
||||
subscription_id: number
|
||||
period_start: string
|
||||
period_end: string
|
||||
calculated_amount: number
|
||||
currency: string
|
||||
status: string
|
||||
created_at: string
|
||||
sent_to_sap: boolean
|
||||
}
|
||||
|
||||
export interface AlertThreshold {
|
||||
id: number
|
||||
name: string
|
||||
subscription_id?: number
|
||||
metric_type: string
|
||||
threshold_value: number
|
||||
comparison_operator: string
|
||||
action: string
|
||||
notification_channels: string[]
|
||||
is_active: boolean
|
||||
last_triggered?: string
|
||||
}
|
||||
|
||||
export interface AlertHistoryItem {
|
||||
id: number
|
||||
threshold_id: number
|
||||
threshold_name: string
|
||||
actual_value: number
|
||||
triggered_at: string
|
||||
action_taken?: string
|
||||
notification_sent: boolean
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
id: number
|
||||
url: string
|
||||
name: string
|
||||
event_type?: string
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ScheduledSync {
|
||||
id: number
|
||||
name: string
|
||||
schedule_type: string
|
||||
schedule_config: Record<string, unknown>
|
||||
job_type: string
|
||||
sync_direction: string
|
||||
is_active: boolean
|
||||
last_run: string | null
|
||||
next_run: string | null
|
||||
}
|
||||
|
||||
export interface SapConfig {
|
||||
host: string
|
||||
port: number
|
||||
company_db: string
|
||||
username: string
|
||||
password: string
|
||||
use_ssl: boolean
|
||||
timeout_seconds: number
|
||||
}
|
||||
|
||||
export interface PleskConfig {
|
||||
host: string
|
||||
port: number
|
||||
username: string
|
||||
password: string
|
||||
api_key: string
|
||||
use_https: boolean
|
||||
verify_ssl: boolean
|
||||
two_factor_enabled: boolean
|
||||
two_factor_method: string
|
||||
two_factor_secret: string | null
|
||||
session_id: string | null
|
||||
}
|
||||
|
||||
export interface TestConnectionResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
requires_2fa?: boolean
|
||||
session_id?: string
|
||||
method?: string
|
||||
}
|
||||
|
||||
export interface MessageResponse {
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface ConfigResponse {
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ==================== Auth API ====================
|
||||
|
||||
export async function login(username: string, password: string): Promise<LoginResponse> {
|
||||
return apiJson('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function logout(): Promise<MessageResponse> {
|
||||
return apiJson('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function getCurrentUser(): Promise<User> {
|
||||
return apiJson('/auth/me')
|
||||
}
|
||||
|
||||
export async function changePassword(currentPassword: string, newPassword: string): Promise<MessageResponse> {
|
||||
return apiJson('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password: currentPassword, new_password: newPassword }),
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Config API ====================
|
||||
|
||||
export async function getConfig(): Promise<ConfigResponse> {
|
||||
return apiJson('/config')
|
||||
}
|
||||
|
||||
export async function updateConfig(key: string, value: unknown): Promise<MessageResponse> {
|
||||
return apiJson('/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ key, value }),
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Connection Tests ====================
|
||||
|
||||
export async function testSapConnection(config: SapConfig): Promise<TestConnectionResponse> {
|
||||
return apiJson('/sap/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
}
|
||||
|
||||
export async function testPleskConnection(config: PleskConfig): Promise<TestConnectionResponse> {
|
||||
return apiJson('/plesk/test', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Sync API ====================
|
||||
|
||||
export async function getSyncStatus(): Promise<SyncStatus> {
|
||||
return apiJson('/sync/status')
|
||||
}
|
||||
|
||||
export async function startSync(form: { job_type: string; sync_direction: string }): Promise<MessageResponse> {
|
||||
return apiJson('/sync/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(form),
|
||||
})
|
||||
}
|
||||
|
||||
export async function stopSync(): Promise<MessageResponse> {
|
||||
return apiJson('/sync/stop', { method: 'POST' })
|
||||
}
|
||||
|
||||
export async function getSyncJobs(): Promise<SyncJobsResponse> {
|
||||
return apiJson('/sync/jobs')
|
||||
}
|
||||
|
||||
export async function simulateSync(jobType: string, direction: string): Promise<SyncJobsResponse> {
|
||||
return apiJson('/sync/simulate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ job_type: jobType, sync_direction: direction }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getConflicts(): Promise<Conflict[]> {
|
||||
return apiJson('/sync/conflicts')
|
||||
}
|
||||
|
||||
// ==================== Billing API ====================
|
||||
|
||||
export async function getPricingConfig(): Promise<PricingConfig[]> {
|
||||
return apiJson('/pricing')
|
||||
}
|
||||
|
||||
export async function createPricingConfig(config: Omit<PricingConfig, 'id'>): Promise<PricingConfig> {
|
||||
return apiJson('/pricing', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getBillingRecords(): Promise<BillingRecord[]> {
|
||||
return apiJson('/billing/records')
|
||||
}
|
||||
|
||||
export async function generateInvoice(customerId: number, periodStart: string, periodEnd: string): Promise<MessageResponse> {
|
||||
return apiJson('/billing/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ customer_id: customerId, period_start: periodStart, period_end: periodEnd }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendInvoiceToSap(billingRecordId: number): Promise<MessageResponse> {
|
||||
return apiJson('/billing/send-to-sap', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id: billingRecordId }),
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== Alerts API ====================
|
||||
|
||||
export async function getThresholds(): Promise<AlertThreshold[]> {
|
||||
return apiJson('/alerts/thresholds')
|
||||
}
|
||||
|
||||
export async function createThreshold(threshold: Omit<AlertThreshold, 'id'>): Promise<AlertThreshold> {
|
||||
return apiJson('/alerts/thresholds', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(threshold),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getAlertHistory(): Promise<AlertHistoryItem[]> {
|
||||
return apiJson('/alerts/history')
|
||||
}
|
||||
|
||||
// ==================== Webhooks API ====================
|
||||
|
||||
export async function getWebhooks(): Promise<Webhook[]> {
|
||||
return apiJson('/webhooks')
|
||||
}
|
||||
|
||||
export async function createWebhook(url: string, eventType: string): Promise<Webhook> {
|
||||
return apiJson('/webhooks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url, event_type: eventType }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function deleteWebhook(id: number): Promise<void> {
|
||||
await apiFetch(`/webhooks/${id}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
// ==================== Schedules API ====================
|
||||
|
||||
export async function getScheduledSyncs(): Promise<ScheduledSync[]> {
|
||||
return apiJson('/schedules')
|
||||
}
|
||||
|
||||
export async function createScheduledSync(config: Omit<ScheduledSync, 'id' | 'last_run' | 'next_run'>): Promise<MessageResponse> {
|
||||
return apiJson('/schedules', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(config),
|
||||
})
|
||||
}
|
||||
60
frontend/src/lib/hooks.ts
Executable file
60
frontend/src/lib/hooks.ts
Executable file
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function useInterval(callback: () => void, delay: number | null) {
|
||||
const savedCallback = useRef(callback);
|
||||
|
||||
useEffect(() => {
|
||||
savedCallback.current = callback;
|
||||
}, [callback]);
|
||||
|
||||
useEffect(() => {
|
||||
if (delay === null) return;
|
||||
|
||||
const id = setInterval(() => savedCallback.current(), delay);
|
||||
return () => clearInterval(id);
|
||||
}, [delay]);
|
||||
}
|
||||
|
||||
export function usePolling<T>(
|
||||
fetchFn: () => Promise<T>,
|
||||
intervalMs: number,
|
||||
enabled: boolean = true
|
||||
) {
|
||||
const savedFetch = useRef(fetchFn);
|
||||
|
||||
useEffect(() => {
|
||||
savedFetch.current = fetchFn;
|
||||
}, [fetchFn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const performFetch = async () => {
|
||||
try {
|
||||
await savedFetch.current();
|
||||
} catch (error) {
|
||||
console.error('Polling fetch failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
performFetch();
|
||||
const id = setInterval(performFetch, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [intervalMs, enabled]);
|
||||
}
|
||||
|
||||
export const statusColors: Record<string, 'success' | 'error' | 'primary' | 'warning' | 'default'> = {
|
||||
completed: 'success',
|
||||
failed: 'error',
|
||||
running: 'primary',
|
||||
pending: 'warning',
|
||||
};
|
||||
|
||||
export function getStatusColor(status: string): 'success' | 'error' | 'primary' | 'warning' | 'default' {
|
||||
return statusColors[status] || 'default';
|
||||
}
|
||||
|
||||
export function formatDate(dateString: string | null): string {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleString();
|
||||
}
|
||||
17
frontend/src/lib/logger.ts
Executable file
17
frontend/src/lib/logger.ts
Executable file
@@ -0,0 +1,17 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any */
|
||||
export const logger = {
|
||||
debug: (message: string, ...args: any[]) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.debug(`[DEBUG] ${message}`, ...args);
|
||||
}
|
||||
},
|
||||
info: (message: string, ...args: any[]) => {
|
||||
console.log(`[INFO] ${message}`, ...args);
|
||||
},
|
||||
warn: (message: string, ...args: any[]) => {
|
||||
console.warn(`[WARN] ${message}`, ...args);
|
||||
},
|
||||
error: (message: string, ...args: any[]) => {
|
||||
console.error(`[ERROR] ${message}`, ...args);
|
||||
},
|
||||
};
|
||||
110
frontend/src/lib/validators.ts
Executable file
110
frontend/src/lib/validators.ts
Executable file
@@ -0,0 +1,110 @@
|
||||
export const validators = {
|
||||
username: (value: string): { valid: boolean; error?: string } => {
|
||||
if (!value || value.length < 3) {
|
||||
return { valid: false, error: 'Username must be at least 3 characters' };
|
||||
}
|
||||
if (value.length > 50) {
|
||||
return { valid: false, error: 'Username must not exceed 50 characters' };
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(value)) {
|
||||
return { valid: false, error: 'Username can only contain letters, numbers, and underscores' };
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
email: (value: string): { valid: boolean; error?: string } => {
|
||||
if (!value) {
|
||||
return { valid: false, error: 'Email is required' };
|
||||
}
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
return { valid: false, error: 'Please enter a valid email address' };
|
||||
}
|
||||
if (value.length > 255) {
|
||||
return { valid: false, error: 'Email must not exceed 255 characters' };
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
password: (value: string): { valid: boolean; error?: string } => {
|
||||
if (!value) {
|
||||
return { valid: false, error: 'Password is required' };
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return { valid: false, error: 'Password must be at least 8 characters' };
|
||||
}
|
||||
if (!/[A-Z]/.test(value)) {
|
||||
return { valid: false, error: 'Password must contain at least one uppercase letter' };
|
||||
}
|
||||
if (!/[a-z]/.test(value)) {
|
||||
return { valid: false, error: 'Password must contain at least one lowercase letter' };
|
||||
}
|
||||
if (!/[0-9]/.test(value)) {
|
||||
return { valid: false, error: 'Password must contain at least one digit' };
|
||||
}
|
||||
if (!/[!@#$%^&*(),.?":{}|<>]/.test(value)) {
|
||||
return { valid: false, error: 'Password must contain at least one special character' };
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
host: (value: string): { valid: boolean; error?: string } => {
|
||||
if (!value) {
|
||||
return { valid: false, error: 'Host is required' };
|
||||
}
|
||||
const hostRegex = /^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/;
|
||||
if (!hostRegex.test(value)) {
|
||||
return { valid: false, error: 'Please enter a valid host name or IP address' };
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
port: (value: number): { valid: boolean; error?: string } => {
|
||||
if (value < 1 || value > 65535) {
|
||||
return { valid: false, error: 'Port must be between 1 and 65535' };
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
syncDirection: (value: string): { valid: boolean; error?: string } => {
|
||||
const validDirections = ['bidirectional', 'sap_to_plesk', 'plesk_to_sap'];
|
||||
if (!validDirections.includes(value)) {
|
||||
return { valid: false, error: 'Invalid sync direction' };
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
syncInterval: (value: number): { valid: boolean; error?: string } => {
|
||||
if (value < 1 || value > 1440) {
|
||||
return { valid: false, error: 'Sync interval must be between 1 and 1440 minutes' };
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
conflictResolution: (value: string): { valid: boolean; error?: string } => {
|
||||
const validResolutions = ['sap_first', 'plesk_first', 'manual', 'timestamp_based'];
|
||||
if (!validResolutions.includes(value)) {
|
||||
return { valid: false, error: 'Invalid conflict resolution strategy' };
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
required: (value: string): { valid: boolean; error?: string } => {
|
||||
if (!value || value.trim() === '') {
|
||||
return { valid: false, error: 'This field is required' };
|
||||
}
|
||||
return { valid: true };
|
||||
},
|
||||
|
||||
url: (value: string): { valid: boolean; error?: string } => {
|
||||
if (!value) {
|
||||
return { valid: false, error: 'URL is required' };
|
||||
}
|
||||
try {
|
||||
new URL(value);
|
||||
return { valid: true };
|
||||
} catch {
|
||||
return { valid: false, error: 'Please enter a valid URL' };
|
||||
}
|
||||
},
|
||||
};
|
||||
38
frontend/src/main.tsx
Executable file
38
frontend/src/main.tsx
Executable file
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary.tsx'
|
||||
import './index.css'
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[SAP Sync] React mounting...')
|
||||
|
||||
try {
|
||||
const rootElement = document.getElementById('root')
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element #root not found in DOM')
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement)
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[SAP Sync] React mounted successfully')
|
||||
} catch (error) {
|
||||
console.error('[SAP Sync] Failed to mount React:', error)
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
document.body.innerHTML = [
|
||||
'<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f8fafc;padding:20px;">',
|
||||
'<div style="max-width:600px;width:100%;background:white;padding:32px;border-radius:12px;box-shadow:0 4px 6px rgba(0,0,0,0.1);">',
|
||||
'<h1 style="color:#ef4444;margin-bottom:16px;">Application Failed to Load</h1>',
|
||||
'<p style="color:#64748b;margin-bottom:16px;">There was a critical error starting the application.</p>',
|
||||
'<pre style="background:#f1f5f9;padding:16px;border-radius:8px;overflow:auto;max-height:300px;font-size:12px;">' + errorMsg + '</pre>',
|
||||
'<button onclick="window.location.reload()" style="margin-top:16px;padding:10px 20px;background:#6366f1;color:white;border:none;border-radius:8px;cursor:pointer;font-size:16px;">Reload Page</button>',
|
||||
'</div></div>'
|
||||
].join('')
|
||||
}
|
||||
420
frontend/src/pages/AlertsPage.tsx
Executable file
420
frontend/src/pages/AlertsPage.tsx
Executable file
@@ -0,0 +1,420 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
TextField,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Warning as WarningIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Add as AddIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import { apiJson, apiFetch } from '../lib/api';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
interface AlertThreshold {
|
||||
id: number;
|
||||
name: string;
|
||||
subscription_id?: number;
|
||||
metric_type: string;
|
||||
threshold_value: number;
|
||||
comparison_operator: string;
|
||||
action: string;
|
||||
notification_channels: string[];
|
||||
is_active: boolean;
|
||||
last_triggered?: string;
|
||||
}
|
||||
|
||||
interface AlertHistoryItem {
|
||||
id: number;
|
||||
threshold_id: number;
|
||||
threshold_name: string;
|
||||
actual_value: number;
|
||||
triggered_at: string;
|
||||
action_taken?: string;
|
||||
notification_sent: boolean;
|
||||
}
|
||||
|
||||
const metricLabels: Record<string, string> = {
|
||||
cpu: 'CPU',
|
||||
ram: 'Memory (RAM)',
|
||||
disk: 'Disk Storage',
|
||||
bandwidth: 'Bandwidth',
|
||||
database: 'Database',
|
||||
requests: 'Requests',
|
||||
emails: 'Emails',
|
||||
};
|
||||
|
||||
const operatorLabels: Record<string, string> = {
|
||||
'>': 'Greater than',
|
||||
'>=': 'Greater or equal',
|
||||
'<': 'Less than',
|
||||
'<=': 'Less or equal',
|
||||
'=': 'Equal to',
|
||||
};
|
||||
|
||||
const unitLabels: Record<string, string> = {
|
||||
cpu: '%',
|
||||
ram: 'GB',
|
||||
disk: 'GB',
|
||||
bandwidth: 'GB',
|
||||
database: 'MB',
|
||||
requests: 'count',
|
||||
emails: 'count',
|
||||
};
|
||||
|
||||
const AlertsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [thresholds, setThresholds] = useState<AlertThreshold[]>([]);
|
||||
const [history, setHistory] = useState<AlertHistoryItem[]>([]);
|
||||
const [, setLoading] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingThreshold, setEditingThreshold] = useState<AlertThreshold | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchThresholds();
|
||||
fetchHistory();
|
||||
}, []);
|
||||
|
||||
const fetchThresholds = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiJson<AlertThreshold[]>('/alerts/thresholds');
|
||||
setThresholds(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch thresholds:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHistory = async () => {
|
||||
try {
|
||||
const data = await apiJson<AlertHistoryItem[]>('/alerts/history');
|
||||
setHistory(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch history:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenDialog = (threshold?: AlertThreshold) => {
|
||||
if (threshold) {
|
||||
setEditingThreshold(threshold);
|
||||
} else {
|
||||
setEditingThreshold({
|
||||
id: 0,
|
||||
name: '',
|
||||
metric_type: 'cpu',
|
||||
threshold_value: 80,
|
||||
comparison_operator: '>',
|
||||
action: 'notify',
|
||||
notification_channels: ['email'],
|
||||
is_active: true,
|
||||
});
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingThreshold(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
if (!editingThreshold) return;
|
||||
if (editingThreshold.id === 0) {
|
||||
await apiFetch('/alerts/thresholds', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editingThreshold),
|
||||
});
|
||||
} else {
|
||||
await apiFetch(`/alerts/thresholds/${editingThreshold.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(editingThreshold),
|
||||
});
|
||||
}
|
||||
fetchThresholds();
|
||||
handleCloseDialog();
|
||||
} catch (error) {
|
||||
logger.error('Failed to save threshold:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await apiFetch(`/alerts/thresholds/${id}`, { method: 'DELETE' });
|
||||
fetchThresholds();
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete threshold:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4">{t('alerts.title')}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => handleOpenDialog()}
|
||||
>
|
||||
{t('alerts.addThreshold')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={fetchThresholds}
|
||||
>
|
||||
{t('alerts.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>{t('alerts.thresholds')}</Typography>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('alerts.colName')}</TableCell>
|
||||
<TableCell>{t('alerts.colMetric')}</TableCell>
|
||||
<TableCell>{t('alerts.colThreshold')}</TableCell>
|
||||
<TableCell>{t('alerts.colAction')}</TableCell>
|
||||
<TableCell>{t('alerts.colStatus')}</TableCell>
|
||||
<TableCell>{t('alerts.colLastTriggered')}</TableCell>
|
||||
<TableCell>{t('alerts.colActions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{thresholds.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center">
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('alerts.noThresholds')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
thresholds.map((threshold) => (
|
||||
<TableRow key={threshold.id}>
|
||||
<TableCell>{threshold.name}</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={metricLabels[threshold.metric_type] || threshold.metric_type} size="small" color="primary" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">
|
||||
{threshold.comparison_operator} {threshold.threshold_value} {unitLabels[threshold.metric_type]}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{threshold.action}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={threshold.is_active ? t('alerts.active') : t('alerts.inactive')}
|
||||
color={threshold.is_active ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{threshold.last_triggered
|
||||
? new Date(threshold.last_triggered).toLocaleDateString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => handleOpenDialog(threshold)}
|
||||
>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
onClick={() => handleDelete(threshold.id)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>{t('alerts.recentAlerts')}</Typography>
|
||||
<Box sx={{ height: 400, overflow: 'auto' }}>
|
||||
{history.length === 0 ? (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('alerts.noRecentAlerts')}
|
||||
</Typography>
|
||||
) : (
|
||||
history.slice(0, 20).map((alert) => (
|
||||
<Box key={alert.id} sx={{ mb: 2, p: 2, bgcolor: '#f5f5f5', borderRadius: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<WarningIcon color="warning" fontSize="small" />
|
||||
<Typography variant="body2" fontWeight={600} noWrap>
|
||||
{alert.threshold_name}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{t('alerts.value')}: {alert.actual_value} ({t('alerts.triggeredAt')} {new Date(alert.triggered_at).toLocaleString()})
|
||||
</Typography>
|
||||
{alert.action_taken && (
|
||||
<Typography variant="caption" color="textPrimary" sx={{ display: 'block', mt: 0.5 }}>
|
||||
{t('alerts.action')}: {alert.action_taken}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{editingThreshold?.id === 0 ? t('alerts.addThresholdTitle') : t('alerts.editThresholdTitle')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box component="form" sx={{ mt: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('alerts.name')}
|
||||
value={editingThreshold?.name || ''}
|
||||
onChange={(e) => setEditingThreshold({ ...editingThreshold!, name: e.target.value })}
|
||||
margin="normal"
|
||||
/>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label={t('alerts.metricType')}
|
||||
value={editingThreshold?.metric_type || 'cpu'}
|
||||
onChange={(e) => setEditingThreshold({ ...editingThreshold!, metric_type: e.target.value })}
|
||||
margin="normal"
|
||||
>
|
||||
{Object.keys(metricLabels).map((key) => (
|
||||
<option key={key} value={key}>{metricLabels[key]}</option>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={8}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label={t('alerts.thresholdValue')}
|
||||
value={editingThreshold?.threshold_value}
|
||||
onChange={(e) => setEditingThreshold({ ...editingThreshold!, threshold_value: parseFloat(e.target.value) })}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label={t('alerts.unit')}
|
||||
value={unitLabels[editingThreshold?.metric_type || 'cpu'] || ''}
|
||||
disabled
|
||||
margin="normal"
|
||||
>
|
||||
<option>{unitLabels[editingThreshold?.metric_type || 'cpu'] || ''}</option>
|
||||
</TextField>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label={t('alerts.comparison')}
|
||||
value={editingThreshold?.comparison_operator || '>'}
|
||||
onChange={(e) => setEditingThreshold({ ...editingThreshold!, comparison_operator: e.target.value })}
|
||||
margin="normal"
|
||||
>
|
||||
{Object.keys(operatorLabels).map((op) => (
|
||||
<option key={op} value={op}>{operatorLabels[op]}</option>
|
||||
))}
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label={t('alerts.action')}
|
||||
value={editingThreshold?.action || 'notify'}
|
||||
onChange={(e) => setEditingThreshold({ ...editingThreshold!, action: e.target.value })}
|
||||
margin="normal"
|
||||
>
|
||||
<option value="notify">{t('alerts.notifyOnly')}</option>
|
||||
<option value="notify_and_suspend">{t('alerts.notifySuspend')}</option>
|
||||
<option value="notify_and_limit">{t('alerts.notifyLimit')}</option>
|
||||
</TextField>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label={t('alerts.subscription')}
|
||||
value={editingThreshold?.subscription_id || ''}
|
||||
onChange={(e) => setEditingThreshold({ ...editingThreshold!, subscription_id: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||
margin="normal"
|
||||
>
|
||||
<option value="">{t('alerts.allSubscriptions')}</option>
|
||||
{/* Add subscription options here */}
|
||||
</TextField>
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mt: 2 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingThreshold?.is_active ?? true}
|
||||
onChange={(e) => editingThreshold && setEditingThreshold({ ...editingThreshold, is_active: e.target.checked })}
|
||||
/>
|
||||
<Typography variant="body2" sx={{ ml: 1 }}>{t('alerts.active')}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>{t('common.cancel')}</Button>
|
||||
<Button variant="contained" onClick={handleSave}>
|
||||
{editingThreshold?.id === 0 ? t('common.add') : t('common.save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlertsPage;
|
||||
432
frontend/src/pages/AuditPage.tsx
Executable file
432
frontend/src/pages/AuditPage.tsx
Executable file
@@ -0,0 +1,432 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
TextField,
|
||||
MenuItem,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Refresh as RefreshIcon,
|
||||
Download as DownloadIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import { apiJson, apiFetch } from '../lib/api';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
interface SessionAuditLog {
|
||||
id: number;
|
||||
user_id: number;
|
||||
username?: string;
|
||||
session_id?: string;
|
||||
event: string;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface SyncAuditLog {
|
||||
id: number;
|
||||
sync_job_id: number;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
action: string;
|
||||
status: string;
|
||||
error_message?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
resolution_status?: string;
|
||||
}
|
||||
|
||||
const AuditPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [activeTab, setActiveTab] = useState<'session' | 'sync'>('session');
|
||||
const [sessionLogs, setSessionLogs] = useState<SessionAuditLog[]>([]);
|
||||
const [syncLogs, setSyncLogs] = useState<SyncAuditLog[]>([]);
|
||||
const [, setLoading] = useState(false);
|
||||
|
||||
// Filter state
|
||||
const [eventFilter, setEventFilter] = useState<string>('all');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
|
||||
const [selectedLog, setSelectedLog] = useState<SessionAuditLog | SyncAuditLog | null>(null);
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
|
||||
const fetchAuditLogs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const filters = new URLSearchParams();
|
||||
if (eventFilter !== 'all') filters.append('event_type', eventFilter);
|
||||
if (dateFrom) filters.append('from', dateFrom);
|
||||
if (dateTo) filters.append('to', dateTo);
|
||||
|
||||
const endpoint = activeTab === 'session' ? '/audit/logs' : '/audit/sync-logs';
|
||||
const data = await apiJson<SessionAuditLog[] | SyncAuditLog[]>(endpoint + '?' + filters.toString());
|
||||
|
||||
if (activeTab === 'session') {
|
||||
setSessionLogs(data as SessionAuditLog[]);
|
||||
} else {
|
||||
setSyncLogs(data as SyncAuditLog[]);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch audit logs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeTab, eventFilter, dateFrom, dateTo]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAuditLogs();
|
||||
}, [fetchAuditLogs]);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const response = await apiFetch('/audit/export');
|
||||
if (!response.ok) throw new Error('Export failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
logger.error('Failed to export:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const eventLabels: Record<string, string> = {
|
||||
login: t('audit.userLogin'),
|
||||
logout: t('audit.userLogout'),
|
||||
mfa_enabled: t('audit.mfaEnabled'),
|
||||
mfa_disabled: t('audit.mfaDisabled'),
|
||||
password_changed: t('audit.passwordChanged'),
|
||||
full_sync: t('audit.fullSync'),
|
||||
incremental_sync: t('audit.incrementalSync'),
|
||||
partial_sync: t('audit.partialSync'),
|
||||
manual_sync: t('audit.manualSync'),
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string): 'default' | 'error' | 'warning' | 'success' => {
|
||||
if (status === 'completed' || status === 'success' || status === 'resolved') return 'success';
|
||||
if (status === 'failed' || status === 'error') return 'error';
|
||||
if (status === 'pending' || status === 'running') return 'warning';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const logs = activeTab === 'session' ? sessionLogs : syncLogs;
|
||||
const filteredLogs = logs.filter(log => {
|
||||
if (activeTab === 'session') {
|
||||
const sessionLog = log as SessionAuditLog;
|
||||
if (eventFilter !== 'all' && sessionLog.event !== eventFilter) return false;
|
||||
} else {
|
||||
const syncLog = log as SyncAuditLog;
|
||||
if (eventFilter !== 'all' && syncLog.action !== eventFilter) return false;
|
||||
}
|
||||
if (dateFrom && new Date(log.timestamp) < new Date(dateFrom)) return false;
|
||||
if (dateTo && new Date(log.timestamp) > new Date(dateTo)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h4">{t('audit.title')}</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, alignItems: 'center', mb: 3 }}>
|
||||
{[
|
||||
{ id: 'session', label: t('audit.loginEvents') },
|
||||
{ id: 'sync', label: t('audit.syncEvents') },
|
||||
].map((tab) => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
variant={activeTab === tab.id ? 'contained' : 'outlined'}
|
||||
onClick={() => setActiveTab(tab.id as 'session' | 'sync')}
|
||||
sx={{ minWidth: 150 }}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">{t('audit.filters')}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={handleExport}
|
||||
>
|
||||
{t('audit.exportCsv')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={fetchAuditLogs}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
select
|
||||
label={t('audit.eventType')}
|
||||
value={eventFilter}
|
||||
onChange={(e) => setEventFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">{t('audit.allEvents')}</MenuItem>
|
||||
{activeTab === 'session' ? (
|
||||
<>
|
||||
<MenuItem value="login">{t('audit.userLogin')}</MenuItem>
|
||||
<MenuItem value="logout">{t('audit.userLogout')}</MenuItem>
|
||||
<MenuItem value="mfa_enabled">{t('audit.mfaEnabled')}</MenuItem>
|
||||
<MenuItem value="mfa_disabled">{t('audit.mfaDisabled')}</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem value="full_sync">{t('audit.fullSync')}</MenuItem>
|
||||
<MenuItem value="incremental_sync">{t('audit.incrementalSync')}</MenuItem>
|
||||
<MenuItem value="partial_sync">{t('audit.partialSync')}</MenuItem>
|
||||
<MenuItem value="manual_sync">{t('audit.manualSync')}</MenuItem>
|
||||
</>
|
||||
)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="date"
|
||||
label={t('audit.fromDate')}
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="date"
|
||||
label={t('audit.toDate')}
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('audit.auditLogs')} ({filteredLogs.length} {t('audit.records')})
|
||||
</Typography>
|
||||
|
||||
<TableContainer sx={{ maxHeight: 600 }}>
|
||||
<Table stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('audit.colId')}</TableCell>
|
||||
{activeTab === 'session' ? (
|
||||
<>
|
||||
<TableCell>{t('audit.colUser')}</TableCell>
|
||||
<TableCell>{t('audit.colEvent')}</TableCell>
|
||||
<TableCell>{t('audit.colIpAddress')}</TableCell>
|
||||
<TableCell>{t('audit.colUserAgent')}</TableCell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableCell>{t('audit.colJobId')}</TableCell>
|
||||
<TableCell>{t('audit.colEntityType')}</TableCell>
|
||||
<TableCell>{t('audit.colEntityId')}</TableCell>
|
||||
<TableCell>{t('audit.colAction')}</TableCell>
|
||||
<TableCell>{t('audit.colStatus')}</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell>{t('audit.colTimestamp')}</TableCell>
|
||||
<TableCell>{t('audit.colActions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredLogs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={activeTab === 'session' ? 7 : 8} align="center">
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('audit.noLogs')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredLogs.map((log: SessionAuditLog | SyncAuditLog) => (
|
||||
<TableRow key={log.id} hover>
|
||||
<TableCell>{log.id}</TableCell>
|
||||
{activeTab === 'session' ? (
|
||||
<>
|
||||
<TableCell>{(log as SessionAuditLog).username || `User ${(log as SessionAuditLog).user_id}`}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={eventLabels[(log as SessionAuditLog).event] || (log as SessionAuditLog).event}
|
||||
size="small"
|
||||
color={'event' in log && (log as SessionAuditLog).event === 'login' ? 'success' : (log as SessionAuditLog).event === 'logout' ? 'primary' : 'default'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{(log as SessionAuditLog).ip_address || '-'}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{(log as SessionAuditLog).user_agent || '-'}
|
||||
</TableCell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableCell>{(log as SyncAuditLog).sync_job_id}</TableCell>
|
||||
<TableCell>{(log as SyncAuditLog).entity_type}</TableCell>
|
||||
<TableCell>{(log as SyncAuditLog).entity_id}</TableCell>
|
||||
<TableCell>{(log as SyncAuditLog).action}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={(log as SyncAuditLog).resolution_status || (log as SyncAuditLog).status}
|
||||
size="small"
|
||||
color={getStatusColor((log as SyncAuditLog).resolution_status || (log as SyncAuditLog).status)}
|
||||
/>
|
||||
</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell>
|
||||
{new Date(log.timestamp).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setSelectedLog(log);
|
||||
setDetailDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('audit.details')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={detailDialogOpen} onClose={() => setDetailDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
{t('audit.detailsTitle')}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedLog && (
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('audit.colId')}</Typography>
|
||||
<Typography variant="body1">{selectedLog.id}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('audit.colTimestamp')}</Typography>
|
||||
<Typography variant="body1">
|
||||
{new Date(selectedLog.timestamp).toLocaleString()}
|
||||
</Typography>
|
||||
</Grid>
|
||||
|
||||
{activeTab === 'session' && (
|
||||
<>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('audit.colUser')}</Typography>
|
||||
<Typography variant="body1">{(selectedLog as SessionAuditLog).username || `User ${(selectedLog as SessionAuditLog).user_id}`}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('audit.colEvent')}</Typography>
|
||||
<Typography variant="body1">{eventLabels[(selectedLog as SessionAuditLog).event] || (selectedLog as SessionAuditLog).event}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('audit.colIpAddress')}</Typography>
|
||||
<Typography variant="body1">{(selectedLog as SessionAuditLog).ip_address || '-'}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('audit.colUserAgent')}</Typography>
|
||||
<Typography variant="body2">{(selectedLog as SessionAuditLog).user_agent || '-'}</Typography>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeTab === 'sync' && (
|
||||
<>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('audit.colJobId')}</Typography>
|
||||
<Typography variant="body1">{(selectedLog as SyncAuditLog).sync_job_id}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('audit.colEntityType')}</Typography>
|
||||
<Typography variant="body1">{(selectedLog as SyncAuditLog).entity_type}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('audit.colEntityId')}</Typography>
|
||||
<Typography variant="body1">{(selectedLog as SyncAuditLog).entity_id}</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('audit.colAction')}</Typography>
|
||||
<Typography variant="body1">{(selectedLog as SyncAuditLog).action}</Typography>
|
||||
</Grid>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('audit.metadata')}</Typography>
|
||||
<Box sx={{
|
||||
bgcolor: '#f5f5f5',
|
||||
p: 2,
|
||||
borderRadius: 1,
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '0.85rem',
|
||||
}}>
|
||||
<pre style={{ margin: 0 }}>
|
||||
{JSON.stringify(selectedLog.metadata, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDetailDialogOpen(false)}>{t('common.close')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuditPage;
|
||||
392
frontend/src/pages/BillingPage.tsx
Executable file
392
frontend/src/pages/BillingPage.tsx
Executable file
@@ -0,0 +1,392 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
Divider,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Receipt as ReceiptIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Description as DescriptionIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import { apiJson, apiFetch } from '../lib/api';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
interface BillingRecord {
|
||||
id: number;
|
||||
subscription_id: number;
|
||||
customer_id: number;
|
||||
customer_name: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
calculated_amount: number;
|
||||
currency: string;
|
||||
invoice_status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface PricingConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
metric_type: string;
|
||||
unit: string;
|
||||
rate_per_unit: number;
|
||||
currency: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface BillingPreview {
|
||||
customer_name: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
line_items: Array<{
|
||||
description: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
rate: number;
|
||||
amount: number;
|
||||
}>;
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
const BillingPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [records, setRecords] = useState<BillingRecord[]>([]);
|
||||
const [pricing, setPricing] = useState<PricingConfig[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false);
|
||||
const [billingPreview, setBillingPreview] = useState<BillingPreview | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBilling();
|
||||
fetchPricing();
|
||||
}, []);
|
||||
|
||||
const fetchBilling = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiJson<BillingRecord[]>('/billing/records');
|
||||
setRecords(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch billing records:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPricing = async () => {
|
||||
try {
|
||||
const data = await apiJson<PricingConfig[]>('/pricing');
|
||||
setPricing(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch pricing:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handlePreview = async (recordId: number) => {
|
||||
try {
|
||||
const preview = await apiJson<BillingPreview>(`/billing/preview/${recordId}`);
|
||||
setBillingPreview(preview);
|
||||
setPreviewDialogOpen(true);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch preview:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendToSAP = async (recordId: number) => {
|
||||
try {
|
||||
await apiFetch(`/billing/send-to-sap/${recordId}`, { method: 'POST' });
|
||||
fetchBilling();
|
||||
} catch (error) {
|
||||
logger.error('Failed to send to SAP:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async (recordId: number, format: 'pdf' | 'csv' | 'xlsx') => {
|
||||
try {
|
||||
const response = await apiFetch(`/reports/export/${format}?billing_id=${recordId}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Export failed');
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `invoice-${recordId}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} catch (error) {
|
||||
logger.error('Failed to export:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string): 'default' | 'error' | 'warning' | 'success' => {
|
||||
switch (status) {
|
||||
case 'draft': return 'default';
|
||||
case 'pending': return 'warning';
|
||||
case 'sent': return 'success';
|
||||
case 'synced': return 'success';
|
||||
case 'failed': return 'error';
|
||||
default: return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string): string => {
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: t('billing.draft'),
|
||||
pending: t('billing.pending'),
|
||||
sent: t('billing.sent'),
|
||||
synced: t('billing.synced'),
|
||||
failed: t('billing.failed'),
|
||||
};
|
||||
return statusLabels[status] || status;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4">{t('billing.title')}</Typography>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<ReceiptIcon />}
|
||||
onClick={() => {
|
||||
// Open generate dialog
|
||||
}}
|
||||
>
|
||||
{t('billing.generate')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={fetchBilling}
|
||||
>
|
||||
{t('billing.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{loading && <LinearProgress />}
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>{t('billing.pricingSummary')}</Typography>
|
||||
<Grid container spacing={2}>
|
||||
{pricing.map((p) => (
|
||||
<Grid item xs={12} md={6} lg={3} key={p.id}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle2" color="textSecondary">
|
||||
{p.name}
|
||||
</Typography>
|
||||
<Typography variant="h6" sx={{ mt: 1 }}>
|
||||
{p.rate_per_unit.toLocaleString()} {p.currency} / {p.unit}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{p.metric_type.toUpperCase()}
|
||||
</Typography>
|
||||
<Chip
|
||||
label={p.is_active ? t('billing.active') : t('billing.inactive')}
|
||||
size="small"
|
||||
color={p.is_active ? 'success' : 'default'}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
{pricing.length === 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('billing.noPricing')}
|
||||
</Typography>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('billing.records')}
|
||||
</Typography>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>{t('billing.customer')}</TableCell>
|
||||
<TableCell>{t('billing.period')}</TableCell>
|
||||
<TableCell align="right">{t('billing.amount')}</TableCell>
|
||||
<TableCell>{t('billing.status')}</TableCell>
|
||||
<TableCell>{t('billing.created')}</TableCell>
|
||||
<TableCell>{t('billing.actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{records.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} align="center">
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('billing.noRecords')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
records.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell>{record.id}</TableCell>
|
||||
<TableCell>{record.customer_name}</TableCell>
|
||||
<TableCell>
|
||||
{new Date(record.period_start).toLocaleDateString()} - {new Date(record.period_end).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
{record.calculated_amount.toLocaleString()} {record.currency}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={getStatusLabel(record.invoice_status)}
|
||||
color={getStatusColor(record.invoice_status)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{new Date(record.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => handlePreview(record.id)}
|
||||
startIcon={<DescriptionIcon />}
|
||||
>
|
||||
{t('billing.preview')}
|
||||
</Button>
|
||||
{record.invoice_status === 'draft' && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => handleSendToSAP(record.id)}
|
||||
startIcon={<CheckCircleIcon />}
|
||||
>
|
||||
{t('billing.syncToSap')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => handleExport(record.id, 'pdf')}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={previewDialogOpen} onClose={() => setPreviewDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<DescriptionIcon />
|
||||
<Typography variant="h6">{t('billing.invoicePreview')}</Typography>
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{billingPreview && (
|
||||
<Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('billing.invoiceTo')}</Typography>
|
||||
<Typography variant="h6">{billingPreview.customer_name}</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" color="textSecondary">{t('billing.period')}</Typography>
|
||||
<Typography variant="h6">
|
||||
{new Date(billingPreview.period_start).toLocaleDateString()} - {new Date(billingPreview.period_end).toLocaleDateString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box>
|
||||
{billingPreview.line_items.length > 0 ? (
|
||||
billingPreview.line_items.map((item, index) => (
|
||||
<Box key={index} sx={{ display: 'flex', justifyContent: 'space-between', py: 1 }}>
|
||||
<Typography>{item.description}</Typography>
|
||||
<Typography>{item.quantity} {item.unit} @ {item.rate} = {item.amount.toFixed(2)} {billingPreview.currency}</Typography>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">{t('billing.noLineItems')}</Typography>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Box sx={{ width: 300 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 1 }}>
|
||||
<Typography variant="subtitle2">{t('billing.subtotal')}</Typography>
|
||||
<Typography variant="subtitle2">{billingPreview.subtotal.toFixed(2)} {billingPreview.currency}</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 1 }}>
|
||||
<Typography variant="subtitle2">{t('billing.tax')}</Typography>
|
||||
<Typography variant="subtitle2">{billingPreview.tax.toFixed(2)} {billingPreview.currency}</Typography>
|
||||
</Box>
|
||||
<Divider sx={{ my: 1 }} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', py: 1 }}>
|
||||
<Typography variant="h6">{t('billing.total')}</Typography>
|
||||
<Typography variant="h6">{billingPreview.total.toFixed(2)} {billingPreview.currency}</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setPreviewDialogOpen(false)}>{t('common.close')}</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default BillingPage;
|
||||
311
frontend/src/pages/ConflictsPage.tsx
Executable file
311
frontend/src/pages/ConflictsPage.tsx
Executable file
@@ -0,0 +1,311 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Chip,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Grid,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Warning as WarningIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Delete as DeleteIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import { apiJson } from '../lib/api';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
interface Conflict {
|
||||
id: number;
|
||||
sync_job_id: number;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
resolution_status: string;
|
||||
source_data: Record<string, unknown>;
|
||||
target_data?: Record<string, unknown>;
|
||||
conflict_details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
type ResolutionAction = 'source' | 'target' | 'merge' | 'skip';
|
||||
|
||||
const ConflictsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [conflicts, setConflicts] = useState<Conflict[]>([]);
|
||||
const [, setLoading] = useState(false);
|
||||
const [selectedConflict, setSelectedConflict] = useState<Conflict | null>(null);
|
||||
const [resolutionDialogOpen, setResolutionDialogOpen] = useState(false);
|
||||
const [resolutionAction, setResolutionAction] = useState<ResolutionAction>('source');
|
||||
|
||||
const fetchConflicts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiJson<Conflict[]>('/sync/conflicts');
|
||||
setConflicts(data);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch conflicts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResolve = async () => {
|
||||
if (!selectedConflict) return;
|
||||
|
||||
try {
|
||||
await apiJson(`/sync/conflicts/${selectedConflict.id}/resolve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
action: resolutionAction,
|
||||
resolved_data: resolutionAction === 'source'
|
||||
? selectedConflict.source_data
|
||||
: selectedConflict.target_data || {},
|
||||
}),
|
||||
});
|
||||
|
||||
setConflicts(conflicts.filter(c => c.id !== selectedConflict.id));
|
||||
setResolutionDialogOpen(false);
|
||||
setSelectedConflict(null);
|
||||
} catch (error) {
|
||||
logger.error('Failed to resolve conflict:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string): 'default' | 'error' | 'warning' | 'success' => {
|
||||
if (status === 'resolved') return 'success';
|
||||
if (status === 'pending') return 'warning';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
||||
<Typography variant="h4">{t('conflicts.title')}</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={fetchConflicts}
|
||||
startIcon={<CheckCircleIcon />}
|
||||
>
|
||||
{t('conflicts.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('conflicts.entityType')}</TableCell>
|
||||
<TableCell>{t('conflicts.entityId')}</TableCell>
|
||||
<TableCell>{t('conflicts.sourceData')}</TableCell>
|
||||
<TableCell>{t('conflicts.targetData')}</TableCell>
|
||||
<TableCell>{t('conflicts.status')}</TableCell>
|
||||
<TableCell>{t('conflicts.actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{conflicts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} align="center">
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('conflicts.noConflicts')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
conflicts.map((conflict) => (
|
||||
<TableRow key={conflict.id}>
|
||||
<TableCell>{conflict.entity_type}</TableCell>
|
||||
<TableCell>{conflict.entity_id}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" noWrap sx={{ maxWidth: 200 }}>
|
||||
{JSON.stringify(conflict.source_data).substr(0, 50)}...
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" noWrap sx={{ maxWidth: 200 }}>
|
||||
{conflict.target_data ? JSON.stringify(conflict.target_data).substr(0, 50) + '...' : '-'}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={conflict.resolution_status}
|
||||
color={getStatusColor(conflict.resolution_status)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setSelectedConflict(conflict);
|
||||
setResolutionDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
{t('conflicts.resolve')}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={resolutionDialogOpen} onClose={() => setResolutionDialogOpen(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>{t('conflicts.resolveConflict')}</DialogTitle>
|
||||
<DialogContent>
|
||||
{selectedConflict && (
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('conflicts.entityType')}: {selectedConflict.entity_type} - {selectedConflict.entity_id}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3} sx={{ mt: 2 }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6">
|
||||
{t('conflicts.source')}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 2, bgcolor: '#f5f5f5', p: 2, borderRadius: 1, maxHeight: 300, overflow: 'auto' }}>
|
||||
<pre style={{ margin: 0 }}>
|
||||
{JSON.stringify(selectedConflict.source_data, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="h6">
|
||||
{t('conflicts.target')}
|
||||
</Typography>
|
||||
<Box sx={{ mt: 2, bgcolor: '#f0f7ff', p: 2, borderRadius: 1, maxHeight: 300, overflow: 'auto' }}>
|
||||
<pre style={{ margin: 0 }}>
|
||||
{JSON.stringify(selectedConflict.target_data || {}, null, 2)}
|
||||
</pre>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 3 }}>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('conflicts.resolutionAction')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Paper
|
||||
elevation={resolutionAction === 'source' ? 3 : 1}
|
||||
onClick={() => setResolutionAction('source')}
|
||||
sx={{
|
||||
p: 2,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
border: resolutionAction === 'source' ? '2px solid ' : '1px solid ',
|
||||
borderColor: resolutionAction === 'source' ? 'primary.main' : 'grey.300',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon color="primary" />
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
{t('conflicts.keepSource')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Paper
|
||||
elevation={resolutionAction === 'target' ? 3 : 1}
|
||||
onClick={() => setResolutionAction('target')}
|
||||
sx={{
|
||||
p: 2,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
border: resolutionAction === 'target' ? '2px solid ' : '1px solid ',
|
||||
borderColor: resolutionAction === 'target' ? 'primary.main' : 'grey.300',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<CheckCircleIcon color="primary" />
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
{t('conflicts.keepTarget')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Paper
|
||||
elevation={resolutionAction === 'merge' ? 3 : 1}
|
||||
onClick={() => setResolutionAction('merge')}
|
||||
sx={{
|
||||
p: 2,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
border: resolutionAction === 'merge' ? '2px solid ' : '1px solid ',
|
||||
borderColor: resolutionAction === 'merge' ? 'primary.main' : 'grey.300',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<WarningIcon color="warning" />
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
{t('conflicts.merge')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Paper
|
||||
elevation={resolutionAction === 'skip' ? 3 : 1}
|
||||
onClick={() => setResolutionAction('skip')}
|
||||
sx={{
|
||||
p: 2,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'center',
|
||||
border: resolutionAction === 'skip' ? '2px solid ' : '1px solid ',
|
||||
borderColor: resolutionAction === 'skip' ? 'primary.main' : 'grey.300',
|
||||
transition: 'all 0.2s'
|
||||
}}
|
||||
>
|
||||
<DeleteIcon color="error" />
|
||||
<Typography variant="body2" sx={{ mt: 1 }}>
|
||||
{t('conflicts.skip')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setResolutionDialogOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleResolve}>
|
||||
{t('conflicts.applyResolution')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConflictsPage;
|
||||
240
frontend/src/pages/DashboardPage.tsx
Executable file
240
frontend/src/pages/DashboardPage.tsx
Executable file
@@ -0,0 +1,240 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
Typography,
|
||||
CircularProgress,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import { apiFetch } from '../lib/api';
|
||||
import { usePolling } from '../lib/hooks';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
interface SyncStatus {
|
||||
is_running: boolean;
|
||||
current_job: unknown;
|
||||
recent_jobs: Array<{
|
||||
id: number;
|
||||
job_type: string;
|
||||
sync_direction: string;
|
||||
status: string;
|
||||
}>;
|
||||
stats: {
|
||||
running: number;
|
||||
completed_today: number;
|
||||
failed_today: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface HealthStatus {
|
||||
status: string;
|
||||
database: {
|
||||
status: string;
|
||||
healthy: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface ServerInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
connection_status: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null);
|
||||
const [healthStatus, setHealthStatus] = useState<HealthStatus | null>(null);
|
||||
const [pleskServers, setPleskServers] = useState<ServerInfo[]>([]);
|
||||
const [sapServers, setSapServers] = useState<ServerInfo[]>([]);
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const [syncResponse, healthResponse, pleskResponse, sapResponse] = await Promise.all([
|
||||
apiFetch('/sync/status'),
|
||||
apiFetch('/health'),
|
||||
apiFetch('/servers/plesk'),
|
||||
apiFetch('/servers/sap'),
|
||||
]);
|
||||
|
||||
if (syncResponse.ok) {
|
||||
setSyncStatus(await syncResponse.json());
|
||||
}
|
||||
|
||||
if (healthResponse.ok) {
|
||||
setHealthStatus(await healthResponse.json());
|
||||
}
|
||||
|
||||
if (pleskResponse.ok) {
|
||||
setPleskServers(await pleskResponse.json());
|
||||
}
|
||||
|
||||
if (sapResponse.ok) {
|
||||
setSapServers(await sapResponse.json());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch dashboard data:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePolling(fetchData, 30000);
|
||||
|
||||
const recentJobs = syncStatus?.recent_jobs ?? [];
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{t('dashboard.title')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('dashboard.sync_status')}
|
||||
</Typography>
|
||||
<Box display="flex" alignItems="center" mb={2}>
|
||||
{syncStatus?.is_running ? (
|
||||
<>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
<Typography>{t('dashboard.sync_running')}</Typography>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircleIcon color="success" sx={{ mr: 1 }} />
|
||||
<Typography>{t('dashboard.no_sync')}</Typography>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('dashboard.running')}: {syncStatus?.stats?.running ?? 0}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('dashboard.completed_today')}: {syncStatus?.stats?.completed_today ?? 0}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('dashboard.failed_today')}: {syncStatus?.stats?.failed_today ?? 0}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('dashboard.health_status')}
|
||||
</Typography>
|
||||
<Box mb={2}>
|
||||
<Box display="flex" alignItems="center" mb={1}>
|
||||
{healthStatus?.database?.healthy ? (
|
||||
<CheckCircleIcon color="success" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<ErrorIcon color="error" sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography>
|
||||
{t('dashboard.internal_db')}: {healthStatus?.database?.healthy ? t('dashboard.connected') : t('dashboard.disconnected')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{pleskServers.length > 0 ? (
|
||||
pleskServers.map((s) => (
|
||||
<Box key={`plesk-${s.id}`} display="flex" alignItems="center" mb={1}>
|
||||
{s.connection_status === 'connected' ? (
|
||||
<CheckCircleIcon color="success" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<ErrorIcon color="warning" sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography>Plesk ({s.name}): {s.connection_status}</Typography>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Box display="flex" alignItems="center" mb={1}>
|
||||
<ErrorIcon color="disabled" sx={{ mr: 1 }} />
|
||||
<Typography color="textSecondary">{t('dashboard.no_plesk')}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{sapServers.length > 0 ? (
|
||||
sapServers.map((s) => (
|
||||
<Box key={`sap-${s.id}`} display="flex" alignItems="center" mb={1}>
|
||||
{s.connection_status === 'connected' ? (
|
||||
<CheckCircleIcon color="success" sx={{ mr: 1 }} />
|
||||
) : (
|
||||
<ErrorIcon color="warning" sx={{ mr: 1 }} />
|
||||
)}
|
||||
<Typography>SAP ({s.name}): {s.connection_status}</Typography>
|
||||
</Box>
|
||||
))
|
||||
) : (
|
||||
<Box display="flex" alignItems="center" mb={1}>
|
||||
<ErrorIcon color="disabled" sx={{ mr: 1 }} />
|
||||
<Typography color="textSecondary">{t('dashboard.no_sap')}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Chip
|
||||
label={healthStatus?.status === 'healthy' ? t('dashboard.healthy') : t('dashboard.unhealthy')}
|
||||
color={healthStatus?.status === 'healthy' ? 'success' : 'error'}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('dashboard.recent_jobs')}
|
||||
</Typography>
|
||||
{recentJobs.length > 0 ? (
|
||||
<Box>
|
||||
{recentJobs.map((job) => (
|
||||
<Box key={job.id} mb={2} p={2} bgcolor="grey.100" borderRadius={1}>
|
||||
<Typography variant="body2">
|
||||
Job #{job.id} - {job.job_type} ({job.sync_direction})
|
||||
</Typography>
|
||||
<Chip
|
||||
label={job.status}
|
||||
size="small"
|
||||
color={
|
||||
job.status === 'completed'
|
||||
? 'success'
|
||||
: job.status === 'failed'
|
||||
? 'error'
|
||||
: 'warning'
|
||||
}
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography color="textSecondary">{t('dashboard.no_recent_jobs')}</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
178
frontend/src/pages/LoginPage.tsx
Executable file
178
frontend/src/pages/LoginPage.tsx
Executable file
@@ -0,0 +1,178 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Container,
|
||||
TextField,
|
||||
Typography,
|
||||
Paper,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
Select,
|
||||
MenuItem,
|
||||
FormControl,
|
||||
} from '@mui/material';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const { login } = useAuth();
|
||||
const { t, language, changeLanguage } = useI18n();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await login(username, password);
|
||||
toast.success(t('login.success'));
|
||||
navigate('/dashboard', { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('login.failedHint');
|
||||
setError(message);
|
||||
toast.error(t('login.failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: 2,
|
||||
}}
|
||||
>
|
||||
<Container component="main" maxWidth="xs" disableGutters>
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
padding: 5,
|
||||
width: '100%',
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderRadius: 3,
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3)',
|
||||
}}
|
||||
>
|
||||
<Box textAlign="center" sx={{ mb: 4 }}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
width: 64,
|
||||
height: 64,
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
borderRadius: '50%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 32, color: 'white' }}>🔄</span>
|
||||
</Box>
|
||||
<Typography component="h1" variant="h4" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
{t('app.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('login.subtitle')}
|
||||
</Typography>
|
||||
<FormControl sx={{ mt: 2, minWidth: 120 }}>
|
||||
<Select
|
||||
value={language}
|
||||
onChange={(e) => changeLanguage(e.target.value)}
|
||||
variant="standard"
|
||||
size="small"
|
||||
>
|
||||
<MenuItem value="de">DE</MenuItem>
|
||||
<MenuItem value="en">EN</MenuItem>
|
||||
<MenuItem value="fr">FR</MenuItem>
|
||||
<MenuItem value="es">ES</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{error && (
|
||||
<Alert severity="error" sx={{ mt: 2, mb: 2 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box component="form" onSubmit={handleSubmit} sx={{ mt: 1 }}>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
id="username"
|
||||
label={t('login.username')}
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
margin="normal"
|
||||
required
|
||||
fullWidth
|
||||
name="password"
|
||||
label={t('login.password')}
|
||||
type="password"
|
||||
id="password"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
sx={{
|
||||
'& .MuiOutlinedInput-root': {
|
||||
borderRadius: 2,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
sx={{
|
||||
mt: 3,
|
||||
mb: 2,
|
||||
py: 1.5,
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
fontSize: '1rem',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
'&:hover': {
|
||||
background: 'linear-gradient(135deg, #5568d3 0%, #63408a 100%)',
|
||||
},
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <CircularProgress size={24} sx={{ color: 'white' }} /> : t('login.submit')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
317
frontend/src/pages/ReportsPage.tsx
Executable file
317
frontend/src/pages/ReportsPage.tsx
Executable file
@@ -0,0 +1,317 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Grid,
|
||||
CircularProgress,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Download as DownloadIcon,
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
} from 'recharts';
|
||||
import toast from 'react-hot-toast';
|
||||
import { apiFetch, getErrorMessage } from '../lib/api';
|
||||
import { logger } from '../lib/logger';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
|
||||
interface SyncStats {
|
||||
running: number;
|
||||
completed_today: number;
|
||||
failed_today: number;
|
||||
}
|
||||
|
||||
interface SyncStatusResponse {
|
||||
stats: SyncStats;
|
||||
}
|
||||
|
||||
interface UsageMetric {
|
||||
id: number;
|
||||
subscription_id: number;
|
||||
metric_type: string;
|
||||
metric_value: number;
|
||||
unit: string;
|
||||
recorded_at: string;
|
||||
}
|
||||
|
||||
const ReportsPage = () => {
|
||||
const { t } = useI18n();
|
||||
const [syncStats, setSyncStats] = useState<SyncStats | null>(null);
|
||||
const [usageMetrics, setUsageMetrics] = useState<UsageMetric[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [reportType, setReportType] = useState('sync');
|
||||
const [dateRange, setDateRange] = useState('7d');
|
||||
|
||||
useEffect(() => {
|
||||
fetchReportData();
|
||||
}, [reportType, dateRange]);
|
||||
|
||||
const fetchReportData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch sync stats
|
||||
const syncResponse = await apiFetch('/sync/status');
|
||||
if (syncResponse.ok) {
|
||||
const data: SyncStatusResponse = await syncResponse.json();
|
||||
setSyncStats(data.stats);
|
||||
}
|
||||
|
||||
// Fetch usage metrics (placeholder)
|
||||
setUsageMetrics([]);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch report data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportReport = async (format: string) => {
|
||||
try {
|
||||
const response = await apiFetch(`/reports/export/${format}?type=${reportType}&range=${dateRange}`);
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error(await getErrorMessage(response, t('reports.exportFailed')));
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${reportType}-report-${dateRange}.${format}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
toast.success(t('reports.downloadSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(t('reports.exportFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = [
|
||||
{ name: 'Mon', completed: 4, failed: 1 },
|
||||
{ name: 'Tue', completed: 3, failed: 0 },
|
||||
{ name: 'Wed', completed: 5, failed: 2 },
|
||||
{ name: 'Thu', completed: 2, failed: 1 },
|
||||
{ name: 'Fri', completed: 6, failed: 0 },
|
||||
{ name: 'Sat', completed: 1, failed: 0 },
|
||||
{ name: 'Sun', completed: 0, failed: 0 },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{t('reports.title')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
{/* Controls */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item>
|
||||
<FormControl sx={{ minWidth: 200 }}>
|
||||
<InputLabel>{t('reports.reportType')}</InputLabel>
|
||||
<Select
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value)}
|
||||
label={t('reports.reportType')}
|
||||
>
|
||||
<MenuItem value="sync">{t('reports.syncHistory')}</MenuItem>
|
||||
<MenuItem value="usage">{t('reports.usageMetrics')}</MenuItem>
|
||||
<MenuItem value="revenue">{t('reports.revenue')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<FormControl sx={{ minWidth: 150 }}>
|
||||
<InputLabel>{t('reports.dateRange')}</InputLabel>
|
||||
<Select
|
||||
value={dateRange}
|
||||
onChange={(e) => setDateRange(e.target.value)}
|
||||
label={t('reports.dateRange')}
|
||||
>
|
||||
<MenuItem value="24h">{t('reports.last24h')}</MenuItem>
|
||||
<MenuItem value="7d">{t('reports.last7d')}</MenuItem>
|
||||
<MenuItem value="30d">{t('reports.last30d')}</MenuItem>
|
||||
<MenuItem value="90d">{t('reports.last90d')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs />
|
||||
<Grid item>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => exportReport('csv')}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => exportReport('xlsx')}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
Excel
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => exportReport('pdf')}
|
||||
>
|
||||
PDF
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
{t('reports.runningJobs')}
|
||||
</Typography>
|
||||
<Typography variant="h4">{syncStats?.running || 0}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
{t('reports.completedToday')}
|
||||
</Typography>
|
||||
<Typography variant="h4" color="success.main">
|
||||
{syncStats?.completed_today || 0}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
{t('reports.failedToday')}
|
||||
</Typography>
|
||||
<Typography variant="h4" color="error.main">
|
||||
{syncStats?.failed_today || 0}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
{t('reports.reportRange')}
|
||||
</Typography>
|
||||
<Typography variant="h4" color="primary.main">
|
||||
{dateRange}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Charts */}
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('reports.syncActivity')}
|
||||
</Typography>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
<Bar dataKey="completed" fill="#4caf50" name={t('reports.completed')} />
|
||||
<Bar dataKey="failed" fill="#f44336" name={t('reports.failed')} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Usage Metrics Table */}
|
||||
{reportType === 'usage' && (
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('reports.usageMetrics')}
|
||||
</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('reports.colId')}</TableCell>
|
||||
<TableCell>{t('reports.colSubscription')}</TableCell>
|
||||
<TableCell>{t('reports.colType')}</TableCell>
|
||||
<TableCell>{t('reports.colValue')}</TableCell>
|
||||
<TableCell>{t('reports.colUnit')}</TableCell>
|
||||
<TableCell>{t('reports.colRecorded')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{usageMetrics.map((metric) => (
|
||||
<TableRow key={metric.id}>
|
||||
<TableCell>{metric.id}</TableCell>
|
||||
<TableCell>{metric.subscription_id}</TableCell>
|
||||
<TableCell>{metric.metric_type}</TableCell>
|
||||
<TableCell>{metric.metric_value}</TableCell>
|
||||
<TableCell>{metric.unit}</TableCell>
|
||||
<TableCell>{new Date(metric.recorded_at).toLocaleString()}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportsPage;
|
||||
623
frontend/src/pages/ServersPage.tsx
Executable file
623
frontend/src/pages/ServersPage.tsx
Executable file
@@ -0,0 +1,623 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Typography,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Card,
|
||||
CardContent,
|
||||
Button,
|
||||
Chip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Grid,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security as SecurityIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Edit as EditIcon,
|
||||
Delete as DeleteIcon,
|
||||
Add as AddIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Error as ErrorIcon,
|
||||
Warning as WarningIcon,
|
||||
} from '@mui/icons-material';
|
||||
import toast from 'react-hot-toast';
|
||||
import { apiJson, apiFetch } from '../lib/api';
|
||||
import { logger } from '../lib/logger';
|
||||
|
||||
interface Server {
|
||||
id: number;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
company_db?: string;
|
||||
connection_status: string;
|
||||
last_connected?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface ServerConfig {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
api_key?: string;
|
||||
company_db?: string;
|
||||
use_https?: boolean;
|
||||
verify_ssl?: boolean;
|
||||
use_ssl?: boolean;
|
||||
}
|
||||
|
||||
interface ConnectionTestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
latency_ms?: number;
|
||||
error?: {
|
||||
error_type: string;
|
||||
error_code: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
};
|
||||
requires_2fa?: boolean;
|
||||
session_id?: string;
|
||||
two_factor_method?: string;
|
||||
}
|
||||
|
||||
|
||||
type ServerType = 'plesk' | 'sap';
|
||||
|
||||
export const ServersPage: React.FC = () => {
|
||||
|
||||
const [pleskServers, setPleskServers] = useState<Server[]>([]);
|
||||
const [sapServers, setSapServers] = useState<Server[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [serverToDelete, setServerToDelete] = useState<{id: number, type: ServerType} | null>(null);
|
||||
const [serverType, setServerType] = useState<ServerType>('plesk');
|
||||
const [editingServer, setEditingServer] = useState<Server | null>(null);
|
||||
const [testingServerId, setTestingServerId] = useState<number | null>(null);
|
||||
const [testResult, setTestResult] = useState<ConnectionTestResult | null>(null);
|
||||
|
||||
const [config, setConfig] = useState<ServerConfig>({
|
||||
name: '',
|
||||
host: '',
|
||||
port: 8443,
|
||||
username: '',
|
||||
password: '',
|
||||
api_key: '',
|
||||
company_db: '',
|
||||
use_https: true,
|
||||
verify_ssl: true,
|
||||
use_ssl: true,
|
||||
});
|
||||
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, []);
|
||||
|
||||
const fetchServers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [pleskData, sapData] = await Promise.all([
|
||||
apiJson<Server[]>('/servers/plesk').catch(() => []),
|
||||
apiJson<Server[]>('/servers/sap').catch(() => []),
|
||||
]);
|
||||
setPleskServers(Array.isArray(pleskData) ? pleskData : []);
|
||||
setSapServers(Array.isArray(sapData) ? sapData : []);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch servers:', error);
|
||||
toast.error('Failed to load servers');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!config.name.trim()) {
|
||||
errors.name = 'Name is required';
|
||||
}
|
||||
|
||||
if (!config.host.trim()) {
|
||||
errors.host = 'Host is required';
|
||||
} else if (!/^[a-zA-Z0-9._-]+(:\d+)?$/.test(config.host) && !/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d+)?$/.test(config.host)) {
|
||||
errors.host = 'Invalid host format';
|
||||
}
|
||||
|
||||
if (config.port < 1 || config.port > 65535) {
|
||||
errors.port = 'Port must be between 1 and 65535';
|
||||
}
|
||||
|
||||
if (serverType === 'plesk') {
|
||||
if (!config.api_key && !config.username) {
|
||||
errors.api_key = 'API key or username is required';
|
||||
}
|
||||
if (config.username && !config.password) {
|
||||
errors.password = 'Password is required when username is provided';
|
||||
}
|
||||
} else {
|
||||
if (!config.company_db?.trim()) {
|
||||
errors.company_db = 'Company database is required';
|
||||
}
|
||||
if (!config.username?.trim()) {
|
||||
errors.username = 'Username is required';
|
||||
}
|
||||
if (!config.password?.trim()) {
|
||||
errors.password = 'Password is required';
|
||||
}
|
||||
}
|
||||
|
||||
setFormErrors(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleOpenDialog = (server?: Server, type?: ServerType) => {
|
||||
if (server) {
|
||||
setServerType(type || (server.host.includes('sap') ? 'sap' as ServerType : 'plesk' as ServerType));
|
||||
setEditingServer(server);
|
||||
setConfig({
|
||||
name: server.name,
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: '',
|
||||
password: '',
|
||||
api_key: '',
|
||||
company_db: server.company_db || '',
|
||||
use_https: true,
|
||||
verify_ssl: true,
|
||||
use_ssl: true,
|
||||
});
|
||||
} else {
|
||||
setServerType(type || 'plesk');
|
||||
setEditingServer(null);
|
||||
setConfig({
|
||||
name: '',
|
||||
host: '',
|
||||
port: type === 'plesk' ? 8443 : 50000,
|
||||
username: '',
|
||||
password: '',
|
||||
api_key: '',
|
||||
company_db: '',
|
||||
use_https: true,
|
||||
verify_ssl: true,
|
||||
use_ssl: true,
|
||||
});
|
||||
}
|
||||
setFormErrors({});
|
||||
setTestResult(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setDialogOpen(false);
|
||||
setEditingServer(null);
|
||||
setFormErrors({});
|
||||
setTestResult(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) {
|
||||
toast.error('Please fix the form errors');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const endpoint = `/servers/${serverType}`;
|
||||
const method = editingServer ? 'PUT' : 'POST';
|
||||
const path = editingServer ? `${endpoint}/${editingServer.id}` : endpoint;
|
||||
|
||||
const response = await apiJson<{message?: string; error?: string}>(path, {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
toast.error(response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(editingServer ? 'Server updated successfully' : 'Server added successfully');
|
||||
fetchServers();
|
||||
handleCloseDialog();
|
||||
} catch (error) {
|
||||
logger.error('Failed to save server:', error);
|
||||
toast.error('Failed to save server');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteClick = (id: number, type: ServerType) => {
|
||||
setServerToDelete({ id, type });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!serverToDelete) return;
|
||||
|
||||
try {
|
||||
await apiFetch(`/servers/${serverToDelete.type}/${serverToDelete.id}`, { method: 'DELETE' });
|
||||
toast.success('Server deleted successfully');
|
||||
fetchServers();
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete server:', error);
|
||||
toast.error('Failed to delete server');
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
setServerToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async (server: Server, type: ServerType) => {
|
||||
setTestingServerId(server.id);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const result = await apiJson<ConnectionTestResult>(`/servers/${type}/${server.id}/test`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
setTestResult(result);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`Connection successful (${result.latency_ms}ms)`);
|
||||
fetchServers(); // Refresh to update status
|
||||
} else if (result.requires_2fa) {
|
||||
toast('Two-factor authentication required', { icon: '🔐' });
|
||||
} else {
|
||||
const errorMsg = result.error?.message || result.message;
|
||||
toast.error(`Connection failed: ${errorMsg}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Connection test failed:', error);
|
||||
toast.error('Connection test failed');
|
||||
} finally {
|
||||
setTestingServerId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string): 'default' | 'error' | 'warning' | 'success' => {
|
||||
switch (status) {
|
||||
case 'connected': return 'success';
|
||||
case 'disconnected': return 'error';
|
||||
case 'unknown': return 'default';
|
||||
default: return 'warning';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected': return <CheckCircleIcon fontSize="small" color="success" />;
|
||||
case 'disconnected': return <ErrorIcon fontSize="small" color="error" />;
|
||||
default: return <WarningIcon fontSize="small" color="warning" />;
|
||||
}
|
||||
};
|
||||
|
||||
const renderServers = (servers: Server[], type: ServerType) => (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h6">
|
||||
{type === 'plesk' ? 'Plesk Servers' : 'SAP Servers'}
|
||||
</Typography>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => handleOpenDialog(undefined, type)}
|
||||
>
|
||||
{type === 'plesk' ? 'Add Plesk Server' : 'Add SAP Server'}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{loading && <LinearProgress sx={{ mb: 2 }} />}
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Host</TableCell>
|
||||
<TableCell>Port</TableCell>
|
||||
{type === 'sap' && <TableCell>Company DB</TableCell>}
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Last Connected</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{servers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={type === 'sap' ? 7 : 6} align="center">
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
No servers configured
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
<TableRow key={server.id}>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SecurityIcon fontSize="small" color={server.is_active ? 'primary' : 'disabled'} />
|
||||
<Typography variant="body2">{server.name}</Typography>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>{server.host}</TableCell>
|
||||
<TableCell>{server.port}</TableCell>
|
||||
{type === 'sap' && <TableCell>{server.company_db || '-'}</TableCell>}
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
{getStatusIcon(server.connection_status)}
|
||||
<Chip
|
||||
label={server.connection_status}
|
||||
color={getStatusColor(server.connection_status)}
|
||||
size="small"
|
||||
/>
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{server.last_connected
|
||||
? new Date(server.last_connected).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Box sx={{ display: 'flex', gap: 0.5 }}>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={testingServerId === server.id ? <CircularProgress size={16} /> : <RefreshIcon />}
|
||||
onClick={() => handleTestConnection(server, type)}
|
||||
disabled={testingServerId === server.id}
|
||||
>
|
||||
{testingServerId === server.id ? 'Testing...' : 'Test'}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
startIcon={<EditIcon />}
|
||||
onClick={() => handleOpenDialog(server, type)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
startIcon={<DeleteIcon />}
|
||||
onClick={() => handleDeleteClick(server.id, type)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="h4">Server Management</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, mb: 3 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={fetchServers}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{renderServers(pleskServers, 'plesk')}
|
||||
<Box sx={{ mb: 3 }} />
|
||||
{renderServers(sapServers, 'sap')}
|
||||
|
||||
{/* Add/Edit Server Dialog */}
|
||||
<Dialog open={dialogOpen} onClose={handleCloseDialog} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
{editingServer
|
||||
? `Edit ${serverType === 'plesk' ? 'Plesk' : 'SAP'} Server`
|
||||
: `Add ${serverType === 'plesk' ? 'Plesk' : 'SAP'} Server`}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box component="form" sx={{ mt: 2 }}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Server Name"
|
||||
value={config.name}
|
||||
onChange={(e) => setConfig({ ...config, name: e.target.value })}
|
||||
margin="normal"
|
||||
required
|
||||
error={!!formErrors.name}
|
||||
helperText={formErrors.name}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={8}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Host"
|
||||
value={config.host}
|
||||
onChange={(e) => setConfig({ ...config, host: e.target.value })}
|
||||
margin="normal"
|
||||
required
|
||||
error={!!formErrors.host}
|
||||
helperText={formErrors.host || 'e.g., plesk.example.com or 192.168.1.1'}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="number"
|
||||
label="Port"
|
||||
value={config.port}
|
||||
onChange={(e) => setConfig({ ...config, port: parseInt(e.target.value) || 0 })}
|
||||
margin="normal"
|
||||
error={!!formErrors.port}
|
||||
helperText={formErrors.port}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{serverType === 'plesk' ? (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="API Key"
|
||||
type="password"
|
||||
value={config.api_key || ''}
|
||||
onChange={(e) => setConfig({ ...config, api_key: e.target.value })}
|
||||
margin="normal"
|
||||
error={!!formErrors.api_key}
|
||||
helperText={formErrors.api_key || 'Provide either API key or username/password'}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username (optional)"
|
||||
value={config.username || ''}
|
||||
onChange={(e) => setConfig({ ...config, username: e.target.value })}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password (optional)"
|
||||
type="password"
|
||||
value={config.password || ''}
|
||||
onChange={(e) => setConfig({ ...config, password: e.target.value })}
|
||||
margin="normal"
|
||||
error={!!formErrors.password}
|
||||
helperText={formErrors.password}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Company Database"
|
||||
value={config.company_db || ''}
|
||||
onChange={(e) => setConfig({ ...config, company_db: e.target.value })}
|
||||
margin="normal"
|
||||
required
|
||||
error={!!formErrors.company_db}
|
||||
helperText={formErrors.company_db || 'e.g., SBODEMOUS'}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Username"
|
||||
value={config.username || ''}
|
||||
onChange={(e) => setConfig({ ...config, username: e.target.value })}
|
||||
margin="normal"
|
||||
required
|
||||
error={!!formErrors.username}
|
||||
helperText={formErrors.username}
|
||||
/>
|
||||
<TextField
|
||||
fullWidth
|
||||
label="Password"
|
||||
type="password"
|
||||
value={config.password || ''}
|
||||
onChange={(e) => setConfig({ ...config, password: e.target.value })}
|
||||
margin="normal"
|
||||
required
|
||||
error={!!formErrors.password}
|
||||
helperText={formErrors.password}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{testResult && (
|
||||
<Alert
|
||||
severity={testResult.success ? 'success' : testResult.requires_2fa ? 'warning' : 'error'}
|
||||
sx={{ mt: 2 }}
|
||||
>
|
||||
<Typography variant="body2">
|
||||
{testResult.message}
|
||||
</Typography>
|
||||
{testResult.latency_ms && (
|
||||
<Typography variant="caption" display="block">
|
||||
Latency: {testResult.latency_ms}ms
|
||||
</Typography>
|
||||
)}
|
||||
{testResult.error && (
|
||||
<Typography variant="caption" display="block" color="error">
|
||||
Error Code: {testResult.error.error_code}
|
||||
</Typography>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseDialog}>Cancel</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
// Test connection with current form data
|
||||
const testServer: Server = {
|
||||
id: 0,
|
||||
name: config.name,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
company_db: config.company_db,
|
||||
connection_status: 'unknown',
|
||||
is_active: true,
|
||||
};
|
||||
handleTestConnection(testServer, serverType);
|
||||
}}
|
||||
disabled={loading || testingServerId !== null}
|
||||
>
|
||||
Test Connection
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={loading}>
|
||||
{editingServer ? 'Update' : 'Add'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onClose={() => setDeleteDialogOpen(false)}>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Are you sure you want to delete this server? This action cannot be undone.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleDeleteConfirm} color="error" variant="contained">
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServersPage;
|
||||
841
frontend/src/pages/SettingsPage.tsx
Executable file
841
frontend/src/pages/SettingsPage.tsx
Executable file
@@ -0,0 +1,841 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
Grid,
|
||||
Paper,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
Switch,
|
||||
FormControlLabel,
|
||||
Chip,
|
||||
IconButton,
|
||||
MenuItem,
|
||||
ListItemIcon,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Security as SecurityIcon,
|
||||
CheckCircle as CheckCircleIcon,
|
||||
Delete as DeleteIcon,
|
||||
ContentCopy as CopyIcon,
|
||||
Add as AddIcon,
|
||||
Webhook as WebhookIcon,
|
||||
PlayArrow as PlayIcon,
|
||||
Stop as StopIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import { apiJson } from '../lib/api';
|
||||
import { logger } from '../lib/logger';
|
||||
import { formatDate } from '../lib/hooks';
|
||||
|
||||
interface MfaSetupResponse {
|
||||
method: string;
|
||||
secret: string;
|
||||
qr_code_url?: string;
|
||||
backup_codes: string[];
|
||||
test_code?: string;
|
||||
}
|
||||
|
||||
const SettingsPage: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const [activeTab] = useState<'profile' | 'security' | 'sync' | 'notifications'>('profile');
|
||||
|
||||
// Profile state
|
||||
const [profile, setProfile] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
fullName: '',
|
||||
company: '',
|
||||
});
|
||||
const [profileLoading, setProfileLoading] = useState(true);
|
||||
const [profileSaving, setProfileSaving] = useState(false);
|
||||
const [profileSuccess, setProfileSuccess] = useState(false);
|
||||
|
||||
// Sync state
|
||||
const [syncStatus, setSyncStatus] = useState({
|
||||
is_running: false,
|
||||
last_sync: null as string | null,
|
||||
next_sync: null as string | null,
|
||||
});
|
||||
const [syncLoading, setSyncLoading] = useState(true);
|
||||
|
||||
// Notifications state
|
||||
const [webhooks, setWebhooks] = useState<{ id: number; url: string; event_type?: string }[]>([]);
|
||||
const [webhookUrl, setWebhookUrl] = useState('');
|
||||
const [webhookType, setWebhookType] = useState('sync_complete');
|
||||
const [webhookLoading, setWebhookLoading] = useState(false);
|
||||
const [emailNotifications, setEmailNotifications] = useState(false);
|
||||
const [webhookNotifications, setWebhookNotifications] = useState(false);
|
||||
const [notificationsLoading, setNotificationsLoading] = useState(true);
|
||||
|
||||
// MFA state
|
||||
const [mfaSetupDialog, setMfaSetupDialog] = useState(false);
|
||||
const [, setMfaSecret] = useState('');
|
||||
const [mfaQrCode, setMfaQrCode] = useState<string | null>(null);
|
||||
const [mfaBackupCodes, setMfaBackupCodes] = useState<string[]>([]);
|
||||
const [mfaCodeInput, setMfaCodeInput] = useState('');
|
||||
const [mfaStep, setMfaStep] = useState<'setup' | 'verify' | 'success'>('setup');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mfaEnabled, setMfaEnabled] = useState(false);
|
||||
|
||||
// Password change
|
||||
const [currentPassword, setCurrentPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState('');
|
||||
const [passwordSuccess, setPasswordSuccess] = useState(false);
|
||||
|
||||
const handleMfaSetup = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await apiJson<MfaSetupResponse>('/auth/mfa/setup');
|
||||
setMfaSecret(data.secret);
|
||||
setMfaQrCode(data.qr_code_url || null);
|
||||
setMfaBackupCodes(data.backup_codes || []);
|
||||
setMfaSetupDialog(true);
|
||||
} catch (error) {
|
||||
logger.error('Failed to setup MFA:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaVerify = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await apiJson('/auth/mfa/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code: mfaCodeInput }),
|
||||
});
|
||||
setMfaStep('success');
|
||||
setMfaEnabled(true);
|
||||
setTimeout(() => {
|
||||
setMfaSetupDialog(false);
|
||||
setMfaStep('setup');
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
toast.error(t('settingsSecurity.mfaInvalidCode'));
|
||||
setMfaCodeInput('');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMfaDisable = async () => {
|
||||
if (window.confirm(t('settingsSecurity.mfaConfirmDisable'))) {
|
||||
try {
|
||||
// Implement disable MFA endpoint
|
||||
setMfaEnabled(false);
|
||||
} catch (error) {
|
||||
logger.error('Failed to disable MFA:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
setPasswordError(t('settingsSecurity.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setPasswordError(t('settingsSecurity.passwordTooShort'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setPasswordError('');
|
||||
await apiJson('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}),
|
||||
});
|
||||
setPasswordSuccess(true);
|
||||
setCurrentPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setTimeout(() => setPasswordSuccess(false), 3000);
|
||||
} catch (error: unknown) {
|
||||
setPasswordError(error instanceof Error ? error.message : 'Failed to change password');
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
// Fetch profile data
|
||||
useEffect(() => {
|
||||
if (activeTab === 'profile') {
|
||||
fetchProfile();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchProfile = async () => {
|
||||
try {
|
||||
const data = await apiJson<{ username: string; email: string }>('/auth/me');
|
||||
setProfile({
|
||||
username: (data.username) || '',
|
||||
email: (data.email) || '',
|
||||
fullName: '',
|
||||
company: '',
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch profile:', error);
|
||||
} finally {
|
||||
setProfileLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
setProfileSaving(true);
|
||||
setProfileSuccess(false);
|
||||
try {
|
||||
await apiJson('/auth/me', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(profile),
|
||||
});
|
||||
setProfileSuccess(true);
|
||||
setTimeout(() => setProfileSuccess(false), 3000);
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save profile');
|
||||
} finally {
|
||||
setProfileSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch sync status
|
||||
useEffect(() => {
|
||||
if (activeTab === 'sync') {
|
||||
fetchSyncStatus();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchSyncStatus = async () => {
|
||||
try {
|
||||
const data = await apiJson<{ is_running: boolean; next_sync?: string }>('/sync/status');
|
||||
setSyncStatus({
|
||||
is_running: data.is_running || false,
|
||||
last_sync: null,
|
||||
next_sync: data.next_sync || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch sync status:', error);
|
||||
} finally {
|
||||
setSyncLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch notifications
|
||||
useEffect(() => {
|
||||
if (activeTab === 'notifications') {
|
||||
fetchNotifications();
|
||||
}
|
||||
}, [activeTab]);
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
try {
|
||||
const [webhooksData, configData] = await Promise.all([
|
||||
apiJson<{ id: number; url: string; event_type?: string }[]>('/webhooks'),
|
||||
apiJson<{ config: Record<string, unknown> }>('/config'),
|
||||
]);
|
||||
|
||||
setWebhooks(Array.isArray(webhooksData) ? webhooksData : []);
|
||||
setEmailNotifications((configData.config?.email_notifications as boolean) || false);
|
||||
setWebhookNotifications((configData.config?.webhook_notifications as boolean) || false);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch notifications:', error);
|
||||
} finally {
|
||||
setNotificationsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddWebhook = async () => {
|
||||
if (!webhookUrl) return;
|
||||
|
||||
setWebhookLoading(true);
|
||||
try {
|
||||
await apiJson('/webhooks', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: webhookUrl, event_type: webhookType }),
|
||||
});
|
||||
setWebhookUrl('');
|
||||
fetchNotifications();
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to add webhook');
|
||||
} finally {
|
||||
setWebhookLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteWebhook = async (id: number) => {
|
||||
if (!window.confirm(t('settingsNotifications.deleteConfirm'))) return;
|
||||
|
||||
try {
|
||||
await apiJson(`/webhooks/${id}`, { method: 'DELETE' });
|
||||
fetchNotifications();
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to delete webhook');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveNotificationSettings = async () => {
|
||||
try {
|
||||
await apiJson('/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
email_notifications: emailNotifications,
|
||||
webhook_notifications: webhookNotifications,
|
||||
}),
|
||||
});
|
||||
toast.success(t('settingsNotifications.success'));
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{t('settings.title')}
|
||||
</Typography>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
{activeTab === 'security' && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('settingsSecurity.title')}
|
||||
</Typography>
|
||||
|
||||
{/* MFA Section */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<SecurityIcon color={mfaEnabled ? 'success' : 'action'} />
|
||||
<Typography variant="subtitle1">{t('settingsSecurity.mfa')}</Typography>
|
||||
</Box>
|
||||
{mfaEnabled ? (
|
||||
<Button variant="outlined" color="error" onClick={handleMfaDisable}>
|
||||
{t('settingsSecurity.disableMfa')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="contained" startIcon={<SecurityIcon />} onClick={handleMfaSetup}>
|
||||
{t('settingsSecurity.enableMfa')}
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
{mfaEnabled && (
|
||||
<Alert severity="success">
|
||||
{t('settingsSecurity.mfaEnabled')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', my: 3 }} />
|
||||
|
||||
{/* Password Change Section */}
|
||||
<Box>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('settingsSecurity.changePassword')}
|
||||
</Typography>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{t('settingsSecurity.passwordHint')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('settingsSecurity.currentPassword')}
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
disabled={passwordSuccess}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('settingsSecurity.newPassword')}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
disabled={passwordSuccess}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
type="password"
|
||||
label={t('settingsSecurity.confirmPassword')}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={passwordSuccess}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
{passwordError && (
|
||||
<Alert severity="error" sx={{ mt: 2 }}>
|
||||
{passwordError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{passwordSuccess && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
{t('settingsSecurity.passwordChanged')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleChangePassword}
|
||||
disabled={passwordSuccess}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
{t('settingsSecurity.changePassword')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeTab === 'profile' && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('settingsProfile.title')}
|
||||
</Typography>
|
||||
|
||||
{profileLoading ? (
|
||||
<Box display="flex" justifyContent="center" py={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settingsProfile.username')}
|
||||
value={profile.username}
|
||||
disabled
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settingsProfile.email')}
|
||||
value={profile.email}
|
||||
disabled
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settingsProfile.fullName')}
|
||||
value={profile.fullName}
|
||||
onChange={(e) => setProfile({ ...profile, fullName: e.target.value })}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settingsProfile.company')}
|
||||
value={profile.company}
|
||||
onChange={(e) => setProfile({ ...profile, company: e.target.value })}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSaveProfile}
|
||||
disabled={profileSaving}
|
||||
sx={{ minWidth: 120 }}
|
||||
>
|
||||
{profileSaving ? t('common.save') : t('settingsProfile.save')}
|
||||
</Button>
|
||||
{profileSuccess && (
|
||||
<Alert severity="success" sx={{ mt: 2 }}>
|
||||
{t('settingsProfile.success')}
|
||||
</Alert>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeTab === 'sync' && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('settingsSync.title')}
|
||||
</Typography>
|
||||
|
||||
{syncLoading ? (
|
||||
<Box display="flex" justifyContent="center" py={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box display="flex" alignItems="center">
|
||||
{syncStatus.is_running && (
|
||||
<Box display="flex" alignItems="center" mr={2}>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
<Typography>{t('sync.running')}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Chip
|
||||
label={syncStatus.is_running ? t('sync.running') : t('sync.idle')}
|
||||
color={syncStatus.is_running ? 'primary' : 'default'}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={() => { /* TODO: Start sync */ }}
|
||||
disabled={syncStatus.is_running}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{t('sync.startSync')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<StopIcon />}
|
||||
onClick={() => { /* TODO: Stop sync */ }}
|
||||
disabled={!syncStatus.is_running}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{t('sync.stopSync')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('settingsSync.status')}
|
||||
</Typography>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('settingsSync.lastSync')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{syncStatus.last_sync ? formatDate(syncStatus.last_sync) : '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('settingsSync.nextSync')}
|
||||
</Typography>
|
||||
<Typography variant="body1">
|
||||
{syncStatus.next_sync ? formatDate(syncStatus.next_sync) : '-'}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{activeTab === 'notifications' && (
|
||||
<Box>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('settingsNotifications.title')}
|
||||
</Typography>
|
||||
|
||||
{notificationsLoading ? (
|
||||
<Box display="flex" justifyContent="center" py={4}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('settingsNotifications.emailNotifications')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{t('settingsNotifications.emailNotificationsDesc')}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={emailNotifications}
|
||||
onChange={(e) => setEmailNotifications(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('settingsNotifications.emailNotifications')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('settingsNotifications.webhookNotifications')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 2 }}>
|
||||
{t('settingsNotifications.webhookNotificationsDesc')}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={webhookNotifications}
|
||||
onChange={(e) => setWebhookNotifications(e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label={t('settingsNotifications.webhookNotifications')}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" gutterBottom>
|
||||
{t('settingsNotifications.webhooks')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={5}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settingsNotifications.webhookUrl')}
|
||||
value={webhookUrl}
|
||||
onChange={(e) => setWebhookUrl(e.target.value)}
|
||||
placeholder="https://example.com/webhook"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('settingsNotifications.webhookType')}
|
||||
value={webhookType}
|
||||
onChange={(e) => setWebhookType(e.target.value)}
|
||||
select
|
||||
>
|
||||
<MenuItem value="sync_complete">Sync Complete</MenuItem>
|
||||
<MenuItem value="sync_failed">Sync Failed</MenuItem>
|
||||
<MenuItem value="alert_triggered">Alert Triggered</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={handleAddWebhook}
|
||||
disabled={webhookLoading || !webhookUrl}
|
||||
>
|
||||
{t('settingsNotifications.addWebhook')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
|
||||
<List>
|
||||
{webhooks.length === 0 ? (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('settingsNotifications.noWebhooks')}
|
||||
</Typography>
|
||||
) : (
|
||||
webhooks.map((webhook) => (
|
||||
<ListItem
|
||||
key={webhook.id}
|
||||
secondaryAction={
|
||||
<IconButton
|
||||
edge="end"
|
||||
onClick={() => handleDeleteWebhook(webhook.id)}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<ListItemIcon>
|
||||
<WebhookIcon color="primary" />
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={webhook.url}
|
||||
secondary={webhook.event_type}
|
||||
/>
|
||||
</ListItem>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={handleSaveNotificationSettings}
|
||||
>
|
||||
{t('settingsNotifications.save')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* MFA Setup Dialog */}
|
||||
<Dialog open={mfaSetupDialog} onClose={() => setMfaSetupDialog(false)} maxWidth="md" fullWidth>
|
||||
<DialogTitle>
|
||||
{mfaStep === 'setup' && t('settingsSecurity.mfaSetupTitle')}
|
||||
{mfaStep === 'verify' && t('settingsSecurity.mfaVerifyTitle')}
|
||||
{mfaStep === 'success' && t('settingsSecurity.mfaSuccessTitle')}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{loading && <CircularProgress sx={{ mb: 2 }} />}
|
||||
|
||||
{mfaStep === 'setup' && (
|
||||
<Box>
|
||||
<Typography variant="body1" paragraph>
|
||||
{t('settingsSecurity.mfaSteps')}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>{t('settingsSecurity.mfaStep1')}</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 2, textAlign: 'center' }}>
|
||||
{mfaQrCode && <QRCodeSVG value={mfaQrCode} size={200} />}
|
||||
<Typography variant="caption" color="textSecondary" sx={{ mt: 1, display: 'block' }}>
|
||||
{t('settingsSecurity.mfaScanHint')}
|
||||
</Typography>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>{t('settingsSecurity.mfaStep2')}</Typography>
|
||||
<Alert severity="warning">
|
||||
{t('settingsSecurity.mfaBackupWarning')}
|
||||
</Alert>
|
||||
<List sx={{ mt: 1, bgcolor: '#f5f5f5', p: 2, borderRadius: 1, maxHeight: 200, overflow: 'auto' }}>
|
||||
{mfaBackupCodes.map((code, index) => (
|
||||
<ListItem key={index}>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Typography variant="body2" fontWeight={600}>{code}</Typography>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => copyToClipboard(code)}
|
||||
startIcon={<CopyIcon fontSize="small" />}
|
||||
>
|
||||
{t('settingsSecurity.copy')}
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<Typography variant="subtitle2" gutterBottom>{t('settingsSecurity.mfaStep3')}</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
label={t('settingsSecurity.verificationCode')}
|
||||
value={mfaCodeInput}
|
||||
onChange={(e) => setMfaCodeInput(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000 000"
|
||||
margin="normal"
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, textAlign: 'center', mb: 0 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={mfaCodeInput.length !== 6}
|
||||
onClick={() => setMfaStep('verify')}
|
||||
>
|
||||
{t('settingsSecurity.continueVerify')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{mfaStep === 'verify' && (
|
||||
<Box sx={{ p: 2 }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
disabled={loading}
|
||||
onClick={handleMfaVerify}
|
||||
fullWidth
|
||||
>
|
||||
{t('settingsSecurity.verifyEnable')}
|
||||
</Button>
|
||||
<Box sx={{ mt: 1, textAlign: 'center' }}>
|
||||
<Button onClick={() => setMfaStep('setup')} size="small">
|
||||
Back
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{mfaStep === 'success' && (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<CheckCircleIcon color="success" sx={{ fontSize: 80, mb: 2 }} />
|
||||
<Typography variant="h6" paragraph>
|
||||
{t('settingsSecurity.mfaSuccess')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('settingsSecurity.mfaSuccessHint')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
{mfaStep !== 'success' && (
|
||||
<DialogActions>
|
||||
<Button onClick={() => setMfaSetupDialog(false)}>{t('common.cancel')}</Button>
|
||||
</DialogActions>
|
||||
)}
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
763
frontend/src/pages/SetupWizardPage.tsx
Executable file
763
frontend/src/pages/SetupWizardPage.tsx
Executable file
@@ -0,0 +1,763 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
TextField,
|
||||
Stepper,
|
||||
Step,
|
||||
StepLabel,
|
||||
Grid,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
InputAdornment,
|
||||
IconButton,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
FormControlLabel,
|
||||
Switch,
|
||||
Chip,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
Visibility,
|
||||
VisibilityOff,
|
||||
Cloud as PleskIcon,
|
||||
Business as SapIcon,
|
||||
Check as CheckIcon,
|
||||
Key as KeyIcon,
|
||||
Security as SecurityIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface PleskConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
api_key: string;
|
||||
use_https: boolean;
|
||||
verify_ssl: boolean;
|
||||
two_factor_enabled: boolean;
|
||||
two_factor_method: 'totp' | 'sms' | 'email' | 'none';
|
||||
two_factor_secret?: string;
|
||||
}
|
||||
|
||||
interface SapConfig {
|
||||
host: string;
|
||||
port: number;
|
||||
company_db: string;
|
||||
username: string;
|
||||
password: string;
|
||||
use_ssl: boolean;
|
||||
timeout_seconds: number;
|
||||
}
|
||||
|
||||
interface SyncConfig {
|
||||
sync_direction: string;
|
||||
sync_interval_minutes: number;
|
||||
conflict_resolution: string;
|
||||
auto_sync_enabled: boolean;
|
||||
}
|
||||
|
||||
const SetupWizardPage = () => {
|
||||
const { t } = useI18n();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testResults, setTestResults] = useState<{ plesk: boolean | null; sap: boolean | null }>({
|
||||
plesk: null,
|
||||
sap: null,
|
||||
});
|
||||
|
||||
const [showPleskPassword, setShowPleskPassword] = useState(false);
|
||||
const [showSapPassword, setShowSapPassword] = useState(false);
|
||||
|
||||
// 2FA Dialog State
|
||||
const [twoFactorDialogOpen, setTwoFactorDialogOpen] = useState(false);
|
||||
const [twoFactorCode, setTwoFactorCode] = useState('');
|
||||
const [twoFactorWaiting, setTwoFactorWaiting] = useState(false);
|
||||
const [twoFactorChannel, setTwoFactorChannel] = useState<string>('');
|
||||
const [twoFactorPrompt, setTwoFactorPrompt] = useState<string>('');
|
||||
const [connectionSession, setConnectionSession] = useState<string | null>(null);
|
||||
|
||||
const [pleskConfig, setPleskConfig] = useState<PleskConfig>({
|
||||
host: '',
|
||||
port: 8443,
|
||||
username: '',
|
||||
password: '',
|
||||
api_key: '',
|
||||
use_https: true,
|
||||
verify_ssl: true,
|
||||
two_factor_enabled: false,
|
||||
two_factor_method: 'none',
|
||||
two_factor_secret: '',
|
||||
});
|
||||
|
||||
const [sapConfig, setSapConfig] = useState<SapConfig>({
|
||||
host: '',
|
||||
port: 50000,
|
||||
company_db: '',
|
||||
username: '',
|
||||
password: '',
|
||||
use_ssl: false,
|
||||
timeout_seconds: 30,
|
||||
});
|
||||
|
||||
const [syncConfig, setSyncConfig] = useState<SyncConfig>({
|
||||
sync_direction: 'sap_to_plesk',
|
||||
sync_interval_minutes: 60,
|
||||
conflict_resolution: 'timestamp_based',
|
||||
auto_sync_enabled: true,
|
||||
});
|
||||
|
||||
const steps = [
|
||||
{ label: t('wizard.welcome'), icon: null },
|
||||
{ label: t('wizard.plesk'), icon: <PleskIcon /> },
|
||||
{ label: t('wizard.sap'), icon: <SapIcon /> },
|
||||
{ label: t('wizard.sync'), icon: null },
|
||||
{ label: t('wizard.complete'), icon: null },
|
||||
];
|
||||
|
||||
const handleNext = () => {
|
||||
setActiveStep((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
setActiveStep((prev) => prev - 1);
|
||||
};
|
||||
|
||||
const testPleskConnection = async () => {
|
||||
setLoading(true);
|
||||
setTestResults((prev) => ({ ...prev, plesk: null }));
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/test-plesk', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
...pleskConfig,
|
||||
session_id: connectionSession,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Check if 2FA is required
|
||||
if (data.requires_2fa) {
|
||||
setTwoFactorChannel(data.channel || 'app');
|
||||
setTwoFactorPrompt(data.prompt || t('wizard.2fa_enter_code'));
|
||||
setConnectionSession(data.session_id);
|
||||
setTwoFactorDialogOpen(true);
|
||||
setTwoFactorWaiting(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const success = response.ok && data.success;
|
||||
setTestResults((prev) => ({ ...prev, plesk: success }));
|
||||
|
||||
if (success) {
|
||||
toast.success(t('wizard.plesk_success'));
|
||||
} else {
|
||||
toast.error(data.error || t('wizard.plesk_error'));
|
||||
}
|
||||
} catch (err) {
|
||||
setTestResults((prev) => ({ ...prev, plesk: false }));
|
||||
toast.error(t('wizard.connection_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitTwoFactorCode = async () => {
|
||||
if (!twoFactorCode) {
|
||||
toast.error(t('wizard.2fa_code_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/config/plesk2fa', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
session_id: connectionSession,
|
||||
code: twoFactorCode,
|
||||
host: pleskConfig.host,
|
||||
port: pleskConfig.port,
|
||||
username: pleskConfig.username,
|
||||
password: pleskConfig.password,
|
||||
api_key: pleskConfig.api_key,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setTwoFactorDialogOpen(false);
|
||||
setTwoFactorCode('');
|
||||
setConnectionSession(null);
|
||||
setTestResults((prev) => ({ ...prev, plesk: true }));
|
||||
toast.success(t('wizard.plesk_success'));
|
||||
} else if (data.requires_2fa) {
|
||||
// Still need another code
|
||||
setTwoFactorPrompt(data.prompt || t('wizard.2fa_invalid'));
|
||||
setTwoFactorCode('');
|
||||
} else {
|
||||
toast.error(data.error || t('wizard.2fa_invalid'));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('wizard.connection_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelTwoFactor = () => {
|
||||
setTwoFactorDialogOpen(false);
|
||||
setTwoFactorCode('');
|
||||
setConnectionSession(null);
|
||||
setTwoFactorWaiting(false);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const testSapConnection = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/config/test-sap', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(sapConfig),
|
||||
});
|
||||
|
||||
const success = response.ok;
|
||||
setTestResults((prev) => ({ ...prev, sap: success }));
|
||||
|
||||
if (success) {
|
||||
toast.success(t('wizard.sap_success'));
|
||||
} else {
|
||||
toast.error(t('wizard.sap_error'));
|
||||
}
|
||||
} catch {
|
||||
setTestResults((prev) => ({ ...prev, sap: false }));
|
||||
toast.error(t('wizard.connection_failed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfiguration = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/config/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
plesk: pleskConfig,
|
||||
sap: sapConfig,
|
||||
sync: syncConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
toast.success(t('wizard.save_success'));
|
||||
handleNext();
|
||||
} else {
|
||||
toast.error(t('wizard.save_error'));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('wizard.save_error'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderWelcomeStep = () => (
|
||||
<Box textAlign="center">
|
||||
<Typography variant="h4" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
{t('wizard.welcome_title')}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary" sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}>
|
||||
{t('wizard.welcome_desc')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3} sx={{ mt: 2, maxWidth: 700, mx: 'auto' }}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined" sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<PleskIcon sx={{ fontSize: 48, color: 'primary.main', mb: 1 }} />
|
||||
<Typography variant="h6">{t('wizard.plesk_setup')}</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('wizard.plesk_setup_desc')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card variant="outlined" sx={{ height: '100%' }}>
|
||||
<CardContent>
|
||||
<SapIcon sx={{ fontSize: 48, color: 'secondary.main', mb: 1 }} />
|
||||
<Typography variant="h6">{t('wizard.sap_setup')}</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('wizard.sap_setup_desc')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderPleskStep = () => (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
{t('wizard.plesk_config')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
|
||||
{t('wizard.plesk_config_desc')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wizard.plesk_host')}
|
||||
placeholder="plesk.example.com"
|
||||
value={pleskConfig.host}
|
||||
onChange={(e) => setPleskConfig({ ...pleskConfig, host: e.target.value })}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wizard.port')}
|
||||
type="number"
|
||||
value={pleskConfig.port}
|
||||
onChange={(e) => setPleskConfig({ ...pleskConfig, port: parseInt(e.target.value) || 8443 })}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wizard.username')}
|
||||
value={pleskConfig.username}
|
||||
onChange={(e) => setPleskConfig({ ...pleskConfig, username: e.target.value })}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wizard.password')}
|
||||
type={showPleskPassword ? 'text' : 'password'}
|
||||
value={pleskConfig.password}
|
||||
onChange={(e) => setPleskConfig({ ...pleskConfig, password: e.target.value })}
|
||||
margin="normal"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowPleskPassword(!showPleskPassword)} edge="end">
|
||||
{showPleskPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wizard.api_key')}
|
||||
value={pleskConfig.api_key}
|
||||
onChange={(e) => setPleskConfig({ ...pleskConfig, api_key: e.target.value })}
|
||||
margin="normal"
|
||||
helperText={t('wizard.api_key_helper')}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
{/* 2FA Configuration Section */}
|
||||
<Grid item xs={12}>
|
||||
<Card variant="outlined" sx={{ mt: 2 }}>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
|
||||
<SecurityIcon sx={{ mr: 1, color: 'primary.main' }} />
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{t('wizard.2fa_section')}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={pleskConfig.two_factor_enabled}
|
||||
onChange={(e) => setPleskConfig({
|
||||
...pleskConfig,
|
||||
two_factor_enabled: e.target.checked,
|
||||
two_factor_method: e.target.checked ? 'totp' : 'none'
|
||||
})}
|
||||
/>
|
||||
}
|
||||
label={t('wizard.2fa_enabled')}
|
||||
/>
|
||||
|
||||
{pleskConfig.two_factor_enabled && (
|
||||
<Box sx={{ mt: 2 }}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('wizard.2fa_method')}</InputLabel>
|
||||
<Select
|
||||
value={pleskConfig.two_factor_method}
|
||||
onChange={(e) => setPleskConfig({
|
||||
...pleskConfig,
|
||||
two_factor_method: e.target.value as 'totp' | 'sms' | 'email' | 'none'
|
||||
})}
|
||||
label={t('wizard.2fa_method')}
|
||||
>
|
||||
<MenuItem value="totp">{t('wizard.2fa_totp')}</MenuItem>
|
||||
<MenuItem value="sms">{t('wizard.2fa_sms')}</MenuItem>
|
||||
<MenuItem value="email">{t('wizard.2fa_email')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{pleskConfig.two_factor_method === 'totp' && (
|
||||
<Alert severity="info" sx={{ mt: 2 }}>
|
||||
{t('wizard.2fa_totp_info')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 2 }}>
|
||||
{t('wizard.2fa_tunnel_info')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={testPleskConnection}
|
||||
disabled={loading || !pleskConfig.host}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{t('wizard.test_connection')}
|
||||
</Button>
|
||||
{testResults.plesk !== null && (
|
||||
<Alert severity={testResults.plesk ? 'success' : 'error'} sx={{ flex: 1 }}>
|
||||
{testResults.plesk ? t('wizard.connection_ok') : t('wizard.connection_failed')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 2FA Dialog */}
|
||||
<Dialog open={twoFactorDialogOpen} onClose={cancelTwoFactor} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<KeyIcon sx={{ mr: 1 }} />
|
||||
{t('wizard.2fa_title')}
|
||||
</Box>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{twoFactorWaiting && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<Alert severity="info" icon={<SecurityIcon />}>
|
||||
{twoFactorPrompt}
|
||||
</Alert>
|
||||
<Chip
|
||||
label={t('wizard.2fa_channel') + ': ' + twoFactorChannel}
|
||||
size="small"
|
||||
sx={{ mt: 1 }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
autoFocus
|
||||
label={t('wizard.2fa_code')}
|
||||
value={twoFactorCode}
|
||||
onChange={(e) => setTwoFactorCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
margin="normal"
|
||||
placeholder="000000"
|
||||
inputProps={{
|
||||
maxLength: 6,
|
||||
style: { fontSize: '2rem', textAlign: 'center', letterSpacing: '0.5rem' }
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
submitTwoFactorCode();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 2 }}>
|
||||
{t('wizard.2fa_code_help')}
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={cancelTwoFactor}>
|
||||
{t('wizard.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={submitTwoFactorCode}
|
||||
disabled={twoFactorCode.length < 4 || loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{t('wizard.2fa_verify')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderSapStep = () => (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
{t('wizard.sap_config')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
|
||||
{t('wizard.sap_config_desc')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={8}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wizard.sap_host')}
|
||||
placeholder="sap.example.com"
|
||||
value={sapConfig.host}
|
||||
onChange={(e) => setSapConfig({ ...sapConfig, host: e.target.value })}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wizard.port')}
|
||||
type="number"
|
||||
value={sapConfig.port}
|
||||
onChange={(e) => setSapConfig({ ...sapConfig, port: parseInt(e.target.value) || 50000 })}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wizard.company_db')}
|
||||
placeholder="SBODEMO_DE"
|
||||
value={sapConfig.company_db}
|
||||
onChange={(e) => setSapConfig({ ...sapConfig, company_db: e.target.value })}
|
||||
margin="normal"
|
||||
helperText={t('wizard.company_db_helper')}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wizard.username')}
|
||||
value={sapConfig.username}
|
||||
onChange={(e) => setSapConfig({ ...sapConfig, username: e.target.value })}
|
||||
margin="normal"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wizard.password')}
|
||||
type={showSapPassword ? 'text' : 'password'}
|
||||
value={sapConfig.password}
|
||||
onChange={(e) => setSapConfig({ ...sapConfig, password: e.target.value })}
|
||||
margin="normal"
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton onClick={() => setShowSapPassword(!showSapPassword)} edge="end">
|
||||
{showSapPassword ? <VisibilityOff /> : <Visibility />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Box sx={{ mt: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={testSapConnection}
|
||||
disabled={loading || !sapConfig.host || !sapConfig.company_db}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{t('wizard.test_connection')}
|
||||
</Button>
|
||||
{testResults.sap !== null && (
|
||||
<Alert severity={testResults.sap ? 'success' : 'error'} sx={{ flex: 1 }}>
|
||||
{testResults.sap ? t('wizard.connection_ok') : t('wizard.connection_failed')}
|
||||
</Alert>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderSyncStep = () => (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom sx={{ fontWeight: 600 }}>
|
||||
{t('wizard.sync_config')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
|
||||
{t('wizard.sync_config_desc')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('wizard.sync_direction')}</InputLabel>
|
||||
<Select
|
||||
value={syncConfig.sync_direction}
|
||||
onChange={(e) => setSyncConfig({ ...syncConfig, sync_direction: e.target.value })}
|
||||
label={t('wizard.sync_direction')}
|
||||
>
|
||||
<MenuItem value="sap_to_plesk">SAP → Plesk</MenuItem>
|
||||
<MenuItem value="plesk_to_sap">Plesk → SAP</MenuItem>
|
||||
<MenuItem value="bidirectional">{t('wizard.bidirectional')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={t('wizard.sync_interval')}
|
||||
type="number"
|
||||
value={syncConfig.sync_interval_minutes}
|
||||
onChange={(e) => setSyncConfig({ ...syncConfig, sync_interval_minutes: parseInt(e.target.value) || 60 })}
|
||||
margin="normal"
|
||||
InputProps={{
|
||||
endAdornment: <InputAdornment position="end">{t('wizard.minutes')}</InputAdornment>,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth margin="normal">
|
||||
<InputLabel>{t('wizard.conflict_resolution')}</InputLabel>
|
||||
<Select
|
||||
value={syncConfig.conflict_resolution}
|
||||
onChange={(e) => setSyncConfig({ ...syncConfig, conflict_resolution: e.target.value })}
|
||||
label={t('wizard.conflict_resolution')}
|
||||
>
|
||||
<MenuItem value="sap_priority">{t('wizard.sap_priority')}</MenuItem>
|
||||
<MenuItem value="plesk_priority">{t('wizard.plesk_priority')}</MenuItem>
|
||||
<MenuItem value="timestamp_based">{t('wizard.timestamp_based')}</MenuItem>
|
||||
<MenuItem value="manual">{t('wizard.manual')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderCompleteStep = () => (
|
||||
<Box textAlign="center">
|
||||
<Box
|
||||
sx={{
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: '50%',
|
||||
background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
mx: 'auto',
|
||||
mb: 3,
|
||||
}}
|
||||
>
|
||||
<CheckIcon sx={{ fontSize: 48, color: 'white' }} />
|
||||
</Box>
|
||||
<Typography variant="h4" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
{t('wizard.setup_complete')}
|
||||
</Typography>
|
||||
<Typography variant="body1" color="textSecondary" sx={{ mb: 4, maxWidth: 500, mx: 'auto' }}>
|
||||
{t('wizard.setup_complete_desc')}
|
||||
</Typography>
|
||||
|
||||
<Button variant="contained" size="large" href="/dashboard">
|
||||
{t('wizard.go_dashboard')}
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (activeStep) {
|
||||
case 0:
|
||||
return renderWelcomeStep();
|
||||
case 1:
|
||||
return renderPleskStep();
|
||||
case 2:
|
||||
return renderSapStep();
|
||||
case 3:
|
||||
return renderSyncStep();
|
||||
case 4:
|
||||
return renderCompleteStep();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ maxWidth: 900, mx: 'auto', p: 3 }}>
|
||||
<Card>
|
||||
<CardContent sx={{ p: 4 }}>
|
||||
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
||||
{steps.map((step) => (
|
||||
<Step key={step.label}>
|
||||
<StepLabel>{step.label}</StepLabel>
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
<Box sx={{ minHeight: 400 }}>
|
||||
{renderStepContent()}
|
||||
</Box>
|
||||
|
||||
{activeStep < steps.length - 1 && (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}>
|
||||
<Button
|
||||
disabled={activeStep === 0}
|
||||
onClick={handleBack}
|
||||
variant="outlined"
|
||||
>
|
||||
{t('wizard.back')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={activeStep === steps.length - 2 ? saveConfiguration : handleNext}
|
||||
disabled={loading}
|
||||
startIcon={loading ? <CircularProgress size={20} /> : null}
|
||||
>
|
||||
{activeStep === steps.length - 2 ? t('wizard.complete_setup') : t('wizard.next')}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SetupWizardPage;
|
||||
254
frontend/src/pages/SyncPage.tsx
Executable file
254
frontend/src/pages/SyncPage.tsx
Executable file
@@ -0,0 +1,254 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as PlayIcon,
|
||||
Stop as StopIcon,
|
||||
Refresh as RefreshIcon,
|
||||
} from '@mui/icons-material';
|
||||
import toast from 'react-hot-toast';
|
||||
import { apiJson } from '../lib/api';
|
||||
import { usePolling, getStatusColor, formatDate } from '../lib/hooks';
|
||||
import { logger } from '../lib/logger';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
|
||||
interface SyncJob {
|
||||
id: number;
|
||||
job_type: string;
|
||||
sync_direction: string;
|
||||
status: string;
|
||||
records_processed: number;
|
||||
records_failed: number;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
completed_at: string | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
const SyncPage = () => {
|
||||
const { t } = useI18n();
|
||||
const [jobs, setJobs] = useState<SyncJob[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncDialogOpen, setSyncDialogOpen] = useState(false);
|
||||
const [syncDirection, setSyncDirection] = useState('sap_to_plesk');
|
||||
const [syncType, setSyncType] = useState('incremental_sync');
|
||||
|
||||
const fetchJobs = useCallback(async () => {
|
||||
try {
|
||||
const data = await apiJson<{ jobs: SyncJob[] }>('/sync/jobs');
|
||||
setJobs(data.jobs || []);
|
||||
setIsRunning(data.jobs?.some((job: SyncJob) => job.status === 'running') || false);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch jobs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
usePolling(fetchJobs, 10000);
|
||||
|
||||
const startSync = async () => {
|
||||
try {
|
||||
await apiJson('/sync/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
job_type: syncType,
|
||||
sync_direction: syncDirection,
|
||||
}),
|
||||
});
|
||||
toast.success(t('sync.startedSuccess'));
|
||||
setSyncDialogOpen(false);
|
||||
fetchJobs();
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : t('sync.startFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
const stopSync = async () => {
|
||||
try {
|
||||
await apiJson('/sync/stop', { method: 'POST' });
|
||||
toast.success(t('sync.stoppedSuccess'));
|
||||
fetchJobs();
|
||||
} catch (error: unknown) {
|
||||
toast.error(error instanceof Error ? error.message : t('sync.stopFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="60vh">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
{t('sync.title')}
|
||||
</Typography>
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between">
|
||||
<Box display="flex" alignItems="center">
|
||||
{isRunning && (
|
||||
<Box display="flex" alignItems="center" mr={2}>
|
||||
<CircularProgress size={20} sx={{ mr: 1 }} />
|
||||
<Typography>{t('sync.running')}</Typography>
|
||||
</Box>
|
||||
)}
|
||||
<Chip
|
||||
label={isRunning ? t('sync.running') : t('sync.idle')}
|
||||
color={isRunning ? 'primary' : 'default'}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
startIcon={<PlayIcon />}
|
||||
onClick={() => setSyncDialogOpen(true)}
|
||||
disabled={isRunning}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{t('sync.startSync')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<StopIcon />}
|
||||
onClick={stopSync}
|
||||
disabled={!isRunning}
|
||||
sx={{ mr: 1 }}
|
||||
>
|
||||
{t('sync.stopSync')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={fetchJobs}
|
||||
>
|
||||
{t('common.refresh')}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
{t('sync.syncJobs')}
|
||||
</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('sync.colId')}</TableCell>
|
||||
<TableCell>{t('sync.colType')}</TableCell>
|
||||
<TableCell>{t('sync.colDirection')}</TableCell>
|
||||
<TableCell>{t('sync.colStatus')}</TableCell>
|
||||
<TableCell>{t('sync.colProcessed')}</TableCell>
|
||||
<TableCell>{t('sync.colFailed')}</TableCell>
|
||||
<TableCell>{t('sync.colStarted')}</TableCell>
|
||||
<TableCell>{t('sync.colCompleted')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{jobs.map((job) => (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>{job.id}</TableCell>
|
||||
<TableCell>{job.job_type}</TableCell>
|
||||
<TableCell>{job.sync_direction}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={job.status}
|
||||
color={getStatusColor(job.status)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{job.records_processed}</TableCell>
|
||||
<TableCell>{job.records_failed}</TableCell>
|
||||
<TableCell>{formatDate(job.started_at)}</TableCell>
|
||||
<TableCell>{formatDate(job.completed_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Dialog open={syncDialogOpen} onClose={() => setSyncDialogOpen(false)}>
|
||||
<DialogTitle>{t('sync.startTitle')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Box sx={{ minWidth: 400, mt: 2 }}>
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<InputLabel>{t('sync.syncType')}</InputLabel>
|
||||
<Select
|
||||
value={syncType}
|
||||
onChange={(e) => setSyncType(e.target.value)}
|
||||
label={t('sync.syncType')}
|
||||
>
|
||||
<MenuItem value="full_sync">{t('sync.fullSync')}</MenuItem>
|
||||
<MenuItem value="incremental_sync">{t('sync.incrementalSync')}</MenuItem>
|
||||
<MenuItem value="partial_sync">{t('sync.partialSync')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('sync.direction')}</InputLabel>
|
||||
<Select
|
||||
value={syncDirection}
|
||||
onChange={(e) => setSyncDirection(e.target.value)}
|
||||
label={t('sync.direction')}
|
||||
>
|
||||
<MenuItem value="sap_to_plesk">SAP → Plesk</MenuItem>
|
||||
<MenuItem value="plesk_to_sap">Plesk → SAP</MenuItem>
|
||||
<MenuItem value="bidirectional">Bidirectional</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setSyncDialogOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={startSync} variant="contained" color="primary">
|
||||
{t('sync.start')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncPage;
|
||||
531
frontend/src/pages/SyncSimulationPage.tsx
Executable file
531
frontend/src/pages/SyncSimulationPage.tsx
Executable file
@@ -0,0 +1,531 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Typography,
|
||||
Button,
|
||||
Grid,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
Chip,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
Select,
|
||||
MenuItem,
|
||||
Tabs,
|
||||
Tab,
|
||||
Alert,
|
||||
CircularProgress,
|
||||
LinearProgress,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
PlayArrow as PlayIcon,
|
||||
Refresh as RefreshIcon,
|
||||
Info as InfoIcon,
|
||||
CheckCircle as CheckIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
CompareArrows as CompareIcon,
|
||||
} from '@mui/icons-material';
|
||||
import { useI18n } from '../contexts/I18nContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface SyncItem {
|
||||
id: string;
|
||||
source_id: string;
|
||||
target_id: string | null;
|
||||
name: string;
|
||||
status: 'new' | 'update' | 'conflict' | 'unchanged' | 'delete';
|
||||
source_data: Record<string, unknown>;
|
||||
target_data: Record<string, unknown> | null;
|
||||
diff?: Record<string, { source: unknown; target: unknown }>;
|
||||
}
|
||||
|
||||
interface SimulationResult {
|
||||
data_type: string;
|
||||
direction: string;
|
||||
total_records: number;
|
||||
new: number;
|
||||
updated: number;
|
||||
conflicts: number;
|
||||
unchanged: number;
|
||||
deleted: number;
|
||||
items: SyncItem[];
|
||||
}
|
||||
|
||||
const SyncSimulationPage = () => {
|
||||
const { t } = useI18n();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [simulating, setSimulating] = useState(false);
|
||||
const [dataType, setDataType] = useState('customers');
|
||||
const [direction, setDirection] = useState('sap_to_plesk');
|
||||
const [tabValue, setTabValue] = useState(0);
|
||||
const [selectedItem, setSelectedItem] = useState<SyncItem | null>(null);
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [results, setResults] = useState<SimulationResult | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState<{ plesk: boolean; sap: boolean } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkConnections();
|
||||
}, []);
|
||||
|
||||
const checkConnections = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/setup/status', { credentials: 'include' });
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setConnectionStatus({
|
||||
plesk: data.plesk_configured,
|
||||
sap: data.sap_configured,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast.error('Failed to check connections');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runSimulation = async () => {
|
||||
if (!connectionStatus?.plesk || !connectionStatus?.sap) {
|
||||
toast.error(t('simulation.not_configured'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSimulating(true);
|
||||
setResults(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sync/simulate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
data_type: dataType,
|
||||
direction: direction,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setResults(data);
|
||||
toast.success(t('simulation.complete'));
|
||||
} else {
|
||||
toast.error(t('simulation.error'));
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('simulation.error'));
|
||||
} finally {
|
||||
setSimulating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string): React.ReactElement | undefined => {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
return <CheckIcon fontSize="small" />;
|
||||
case 'update':
|
||||
return <CompareIcon fontSize="small" />;
|
||||
case 'conflict':
|
||||
return <WarningIcon fontSize="small" />;
|
||||
case 'unchanged':
|
||||
return <CheckIcon fontSize="small" />;
|
||||
case 'delete':
|
||||
return <ErrorIcon fontSize="small" />;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string): 'success' | 'info' | 'warning' | 'default' | 'error' => {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
return 'success';
|
||||
case 'update':
|
||||
return 'info';
|
||||
case 'conflict':
|
||||
return 'warning';
|
||||
case 'unchanged':
|
||||
return 'default';
|
||||
case 'delete':
|
||||
return 'error';
|
||||
default:
|
||||
return 'default';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusLabel = (status: string): string => {
|
||||
switch (status) {
|
||||
case 'new':
|
||||
return t('simulation.status_new');
|
||||
case 'update':
|
||||
return t('simulation.status_update');
|
||||
case 'conflict':
|
||||
return t('simulation.status_conflict');
|
||||
case 'unchanged':
|
||||
return t('simulation.status_unchanged');
|
||||
case 'delete':
|
||||
return t('simulation.status_delete');
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const showItemDetails = (item: SyncItem) => {
|
||||
setSelectedItem(item);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const filteredItems = results?.items.filter((item) => {
|
||||
if (tabValue === 0) return true;
|
||||
if (tabValue === 1) return item.status === 'new';
|
||||
if (tabValue === 2) return item.status === 'update';
|
||||
if (tabValue === 3) return item.status === 'conflict';
|
||||
if (tabValue === 4) return item.status === 'unchanged';
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="h4" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
{t('simulation.title')}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 3 }}>
|
||||
{t('simulation.description')}
|
||||
</Typography>
|
||||
|
||||
{(!connectionStatus?.plesk || !connectionStatus?.sap) && (
|
||||
<Alert severity="warning" sx={{ mb: 3 }}>
|
||||
{t('simulation.not_configured')}
|
||||
<Button size="small" href="/setup" sx={{ ml: 2 }}>
|
||||
{t('simulation.go_setup')}
|
||||
</Button>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card sx={{ mb: 3 }}>
|
||||
<CardContent>
|
||||
<Grid container spacing={2} alignItems="center">
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('simulation.data_type')}</InputLabel>
|
||||
<Select
|
||||
value={dataType}
|
||||
onChange={(e) => setDataType(e.target.value)}
|
||||
label={t('simulation.data_type')}
|
||||
>
|
||||
<MenuItem value="customers">{t('simulation.customers')}</MenuItem>
|
||||
<MenuItem value="domains">{t('simulation.domains')}</MenuItem>
|
||||
<MenuItem value="subscriptions">{t('simulation.subscriptions')}</MenuItem>
|
||||
<MenuItem value="invoices">{t('simulation.invoices')}</MenuItem>
|
||||
<MenuItem value="contacts">{t('simulation.contacts')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{t('simulation.direction')}</InputLabel>
|
||||
<Select
|
||||
value={direction}
|
||||
onChange={(e) => setDirection(e.target.value)}
|
||||
label={t('simulation.direction')}
|
||||
>
|
||||
<MenuItem value="sap_to_plesk">SAP → Plesk</MenuItem>
|
||||
<MenuItem value="plesk_to_sap">Plesk → SAP</MenuItem>
|
||||
<MenuItem value="bidirectional">{t('simulation.bidirectional')}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
variant="contained"
|
||||
size="large"
|
||||
startIcon={simulating ? <CircularProgress size={20} /> : <PlayIcon />}
|
||||
onClick={runSimulation}
|
||||
disabled={simulating || !connectionStatus?.plesk || !connectionStatus?.sap}
|
||||
fullWidth
|
||||
>
|
||||
{simulating ? t('simulation.running') : t('simulation.run')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={3}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={checkConnections}
|
||||
disabled={loading}
|
||||
fullWidth
|
||||
>
|
||||
{t('simulation.refresh')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{simulating && (
|
||||
<Box sx={{ mb: 3 }}>
|
||||
<LinearProgress />
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mt: 1, textAlign: 'center' }}>
|
||||
{t('simulation.analyzing')}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{results && (
|
||||
<>
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
<Grid item xs={6} md={2}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="primary">
|
||||
{results.total_records}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('simulation.total')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={2}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="success.main">
|
||||
{results.new}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('simulation.status_new')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={2}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="info.main">
|
||||
{results.updated}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('simulation.status_update')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={2}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="warning.main">
|
||||
{results.conflicts}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('simulation.status_conflict')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={2}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="text.disabled">
|
||||
{results.unchanged}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('simulation.status_unchanged')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={2}>
|
||||
<Card>
|
||||
<CardContent sx={{ textAlign: 'center' }}>
|
||||
<Typography variant="h4" color="error.main">
|
||||
{results.deleted}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('simulation.status_delete')}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Card>
|
||||
<Tabs
|
||||
value={tabValue}
|
||||
onChange={(_, v) => setTabValue(v)}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
>
|
||||
<Tab label={`${t('simulation.all')} (${results.items.length})`} />
|
||||
<Tab label={`${t('simulation.status_new')} (${results.new})`} />
|
||||
<Tab label={`${t('simulation.status_update')} (${results.updated})`} />
|
||||
<Tab label={`${t('simulation.status_conflict')} (${results.conflicts})`} />
|
||||
<Tab label={`${t('simulation.status_unchanged')} (${results.unchanged})`} />
|
||||
</Tabs>
|
||||
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="medium">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('simulation.col_status')}</TableCell>
|
||||
<TableCell>{t('simulation.col_source_id')}</TableCell>
|
||||
<TableCell>{t('simulation.col_target_id')}</TableCell>
|
||||
<TableCell>{t('simulation.col_name')}</TableCell>
|
||||
<TableCell>{t('simulation.col_actions')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredItems?.map((item) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
hover
|
||||
sx={{ cursor: 'pointer' }}
|
||||
onClick={() => showItemDetails(item)}
|
||||
>
|
||||
<TableCell>
|
||||
<Chip
|
||||
icon={getStatusIcon(item.status)}
|
||||
label={getStatusLabel(item.status)}
|
||||
color={getStatusColor(item.status)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{item.source_id}</TableCell>
|
||||
<TableCell>{item.target_id || '-'}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip title={t('simulation.view_details')}>
|
||||
<IconButton size="small">
|
||||
<InfoIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{filteredItems?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} align="center">
|
||||
<Typography variant="body2" color="textSecondary" sx={{ py: 4 }}>
|
||||
{t('simulation.no_records')}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={detailDialogOpen}
|
||||
onClose={() => setDetailDialogOpen(false)}
|
||||
maxWidth="md"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle>
|
||||
{t('simulation.details_title')}: {selectedItem?.name}
|
||||
</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
{selectedItem && (
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Chip
|
||||
icon={getStatusIcon(selectedItem.status)}
|
||||
label={getStatusLabel(selectedItem.status)}
|
||||
color={getStatusColor(selectedItem.status)}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
{t('simulation.source_data')}:
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'grey.50' }}>
|
||||
<pre style={{ margin: 0, fontSize: '0.75rem', overflow: 'auto' }}>
|
||||
{JSON.stringify(selectedItem.source_data, null, 2)}
|
||||
</pre>
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={6}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
{t('simulation.target_data')}:
|
||||
</Typography>
|
||||
<Paper variant="outlined" sx={{ p: 2, bgcolor: 'grey.50' }}>
|
||||
{selectedItem.target_data ? (
|
||||
<pre style={{ margin: 0, fontSize: '0.75rem', overflow: 'auto' }}>
|
||||
{JSON.stringify(selectedItem.target_data, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{t('simulation.no_target_data')}
|
||||
</Typography>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{selectedItem.diff && Object.keys(selectedItem.diff).length > 0 && (
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
|
||||
{t('simulation.differences')}:
|
||||
</Typography>
|
||||
<TableContainer component={Paper} variant="outlined">
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>{t('simulation.col_field')}</TableCell>
|
||||
<TableCell>{t('simulation.col_source_value')}</TableCell>
|
||||
<TableCell>{t('simulation.col_target_value')}</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{Object.entries(selectedItem.diff).map(([field, values]) => (
|
||||
<TableRow key={field}>
|
||||
<TableCell>{field}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{JSON.stringify(values.source)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace' }}>
|
||||
{JSON.stringify(values.target)}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDetailDialogOpen(false)}>
|
||||
{t('simulation.close')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncSimulationPage;
|
||||
1
frontend/src/vite-env.d.ts
vendored
Executable file
1
frontend/src/vite-env.d.ts
vendored
Executable file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
21
frontend/tsconfig.json
Executable file
21
frontend/tsconfig.json
Executable file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Executable file
10
frontend/tsconfig.node.json
Executable file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
frontend/vite.config.ts
Executable file
19
frontend/vite.config.ts
Executable file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user