mirror of
https://github.com/azaion/ui.git
synced 2026-04-22 08:36:34 +00:00
add admin part
This commit is contained in:
@@ -21,3 +21,11 @@
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
node_modules/
|
||||||
|
/test-results/
|
||||||
|
/playwright-report/
|
||||||
|
/blob-report/
|
||||||
|
/playwright/.cache/
|
||||||
|
/playwright/.auth/
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
# Azaion Suite
|
# Azaion Suite
|
||||||
|
|
||||||
Azaion Suite allows to user run detections on videos or photos for military-related objects, like
|
Azaion Suite allows users to run detections on videos or photos for military-related objects, like
|
||||||
military vehicles, tanks, cars, military men, motos, planes, and masked objects.
|
military vehicles, tanks, cars, military men, motos, planes, and masked objects.
|
||||||
Also it allows to do GPS marking by video / photos from GPS camera pointing downwards and start coordinates.
|
Also it allows to do GPS marking by video / photos from GPS camera pointing downwards and start coordinates.
|
||||||
|
|
||||||
### Install
|
## Application Structure
|
||||||
|
|
||||||
|
The application now combines two parts:
|
||||||
|
- **Main Annotator App** (accessible at `/`) - The main annotation interface
|
||||||
|
- **Admin Dashboard** (accessible at `/admin`) - Administrative interface for user management and system controls
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
npm i -g yarn
|
npm i -g yarn
|
||||||
yarn install
|
yarn install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Debug
|
## Development
|
||||||
`yarn start`
|
`yarn start`
|
||||||
|
|
||||||
|
The application will start on http://localhost:3000:
|
||||||
|
- Navigate to `/` for the main annotator interface
|
||||||
|
- Navigate to `/admin` for the admin dashboard (requires admin login)
|
||||||
|
|
||||||
### Build prod build
|
## Build Production
|
||||||
`yarn run build`
|
`yarn run build`
|
||||||
|
|||||||
Generated
+144
-16424
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -10,12 +10,15 @@
|
|||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@testing-library/user-event": "^13.5.0",
|
"@testing-library/user-event": "^13.5.0",
|
||||||
|
"http-proxy-middleware": "^3.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-dropzone": "^14.3.8",
|
"react-dropzone": "^14.3.8",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-scripts": "5.0.1",
|
"react-router-dom": "^6.26.2",
|
||||||
"web-vitals": "^2.1.4"
|
"react-scripts": "^5.0.1",
|
||||||
|
"web-vitals": "^2.1.4",
|
||||||
|
"yarn": "^1.22.22"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
@@ -40,5 +43,12 @@
|
|||||||
"last 1 firefox version",
|
"last 1 firefox version",
|
||||||
"last 1 safari version"
|
"last 1 safari version"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.55.1",
|
||||||
|
"@types/node": "^24.5.2",
|
||||||
|
"@types/react": "^19.1.13",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"typescript": "^4.9.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read environment variables from file.
|
||||||
|
* https://github.com/motdotla/dotenv
|
||||||
|
*/
|
||||||
|
// import dotenv from 'dotenv';
|
||||||
|
// import path from 'path';
|
||||||
|
// dotenv.config({ path: path.resolve(__dirname, '.env') });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* See https://playwright.dev/docs/test-configuration.
|
||||||
|
*/
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
/* Run tests in files in parallel */
|
||||||
|
fullyParallel: true,
|
||||||
|
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
/* Retry on CI only */
|
||||||
|
retries: process.env.CI ? 2 : 0,
|
||||||
|
/* Opt out of parallel tests on CI. */
|
||||||
|
workers: process.env.CI ? 1 : undefined,
|
||||||
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
|
reporter: 'html',
|
||||||
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
|
use: {
|
||||||
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
// baseURL: 'http://localhost:3000',
|
||||||
|
|
||||||
|
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Configure projects for major browsers */
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'firefox',
|
||||||
|
use: { ...devices['Desktop Firefox'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
name: 'webkit',
|
||||||
|
use: { ...devices['Desktop Safari'] },
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Test against mobile viewports. */
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Chrome',
|
||||||
|
// use: { ...devices['Pixel 5'] },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Mobile Safari',
|
||||||
|
// use: { ...devices['iPhone 12'] },
|
||||||
|
// },
|
||||||
|
|
||||||
|
/* Test against branded browsers. */
|
||||||
|
// {
|
||||||
|
// name: 'Microsoft Edge',
|
||||||
|
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'Google Chrome',
|
||||||
|
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
|
||||||
|
/* Run your local dev server before starting the tests */
|
||||||
|
// webServer: {
|
||||||
|
// command: 'npm run start',
|
||||||
|
// url: 'http://localhost:3000',
|
||||||
|
// reuseExistingServer: !process.env.CI,
|
||||||
|
// },
|
||||||
|
});
|
||||||
-13
@@ -1,13 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import AnnotationMain from './components/AnnotationMain/AnnotationMain';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ width: '100%', height: '100vh' }}> {/* Use full viewport height */}
|
|
||||||
<AnnotationMain />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
|
import AnnotationMain from './components/AnnotationMain/AnnotationMain';
|
||||||
|
import Admin from './components/Admin/Admin.tsx';
|
||||||
|
|
||||||
|
const App: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Router>
|
||||||
|
<div style={{ width: '100%', height: '100vh' }}> {/* Use full viewport height */}
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AnnotationMain />} />
|
||||||
|
<Route path="/admin/*" element={<Admin />} />
|
||||||
|
</Routes>
|
||||||
|
</div>
|
||||||
|
</Router>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import AdminLogin from './AdminLogin.tsx';
|
||||||
|
import AdminDashboard from './AdminDashboardNew.tsx';
|
||||||
|
import { ServerInfo } from './types';
|
||||||
|
|
||||||
|
const Admin: React.FC = () => {
|
||||||
|
const [isLoggedIn, setIsLoggedIn] = useState<boolean>(false);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Check if user is already logged in
|
||||||
|
const checkAuth = async (): Promise<void> => {
|
||||||
|
const token = localStorage.getItem('authToken');
|
||||||
|
if (!token) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verify token is still valid
|
||||||
|
let API_BASE = 'https://api.azaion.com';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/__server-info', { method: 'GET' });
|
||||||
|
if (res.ok) {
|
||||||
|
const info: ServerInfo = await res.json();
|
||||||
|
if (info && info.proxyEnabled) {
|
||||||
|
API_BASE = '/proxy';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore; fall back to direct API_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/currentuser`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Authorization': token.startsWith('Bearer ') ? token : `Bearer ${token}`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
const roleVal = (data && (data.role !== undefined ? data.role : (data.data && data.data.role))) ?? null;
|
||||||
|
|
||||||
|
const toNum = (r: any): number => {
|
||||||
|
if (typeof r === 'number') return r;
|
||||||
|
if (typeof r === 'string') {
|
||||||
|
const m = r.match(/-?\d+/);
|
||||||
|
if (m) return Number(m[0]);
|
||||||
|
}
|
||||||
|
return Number(r);
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleNum = toNum(roleVal);
|
||||||
|
|
||||||
|
if (roleNum === 40 || roleNum === 1000) {
|
||||||
|
setIsLoggedIn(true);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('loginResponse');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('loginResponse');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Auth check failed:', error);
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('loginResponse');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLoginSuccess = (): void => {
|
||||||
|
setIsLoggedIn(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = (): void => {
|
||||||
|
setIsLoggedIn(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: '100vh',
|
||||||
|
background: 'linear-gradient(180deg, #0b1022 0%, #0f172a 100%)',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
<div>Loading...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return <AdminLogin onLoginSuccess={handleLoginSuccess} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <AdminDashboard onLogout={handleLogout} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Admin;
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import React, { useEffect, useCallback } from 'react';
|
||||||
|
import AdminHeader from './components/AdminHeader.tsx';
|
||||||
|
import AdminSidebar from './components/AdminSidebar.tsx';
|
||||||
|
import AdminContent from './components/AdminContent.tsx';
|
||||||
|
import useAdminOperations from './hooks/useAdminOperations.ts';
|
||||||
|
import { extractToken } from './utils/parsers.ts';
|
||||||
|
|
||||||
|
interface AdminDashboardProps {
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminDashboard: React.FC<AdminDashboardProps> = ({ onLogout }) => {
|
||||||
|
const {
|
||||||
|
currentOpKey,
|
||||||
|
outputTitle,
|
||||||
|
outputMeta,
|
||||||
|
output,
|
||||||
|
status,
|
||||||
|
users,
|
||||||
|
searchValue,
|
||||||
|
operations,
|
||||||
|
api,
|
||||||
|
setStatusMessage,
|
||||||
|
setOutputMeta,
|
||||||
|
updateOutputTitle,
|
||||||
|
handleUserSearch
|
||||||
|
} = useAdminOperations();
|
||||||
|
|
||||||
|
const ensureAuth = useCallback((): void => {
|
||||||
|
const AUTH_TOKEN = localStorage.getItem('authToken');
|
||||||
|
if (!AUTH_TOKEN) {
|
||||||
|
const last = localStorage.getItem('loginResponse');
|
||||||
|
if (last) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(last);
|
||||||
|
const token = extractToken(data);
|
||||||
|
if (token) localStorage.setItem('authToken', token);
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!localStorage.getItem('authToken')) onLogout();
|
||||||
|
}, [onLogout]);
|
||||||
|
|
||||||
|
const handleOpClick = (opKey: string): void => {
|
||||||
|
if (!operations[opKey].hasForm) {
|
||||||
|
operations[opKey].run();
|
||||||
|
} else {
|
||||||
|
// For operations with forms, show the form
|
||||||
|
operations[opKey].run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = (): void => {
|
||||||
|
localStorage.removeItem('authToken');
|
||||||
|
localStorage.removeItem('loginResponse');
|
||||||
|
onLogout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserUpdate = (): void => {
|
||||||
|
// Refresh the current operation if it's user-related
|
||||||
|
if (currentOpKey === 'list-users') {
|
||||||
|
operations['list-users'].run(searchValue);
|
||||||
|
} else if (currentOpKey === 'current-user') {
|
||||||
|
operations['current-user'].run();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ensureAuth();
|
||||||
|
// Default: run list users
|
||||||
|
operations['list-users'].run();
|
||||||
|
|
||||||
|
// Expose functions for inline onclick handlers (for forms that still use HTML)
|
||||||
|
(window as any).adminDashboard = {
|
||||||
|
submitCreateUser: () => {
|
||||||
|
const emailEl = document.getElementById('createUserEmail') as HTMLInputElement;
|
||||||
|
const passwordEl = document.getElementById('createUserPassword') as HTMLInputElement;
|
||||||
|
const roleEl = document.getElementById('createUserRole') as HTMLSelectElement;
|
||||||
|
|
||||||
|
const email = emailEl?.value?.trim() || '';
|
||||||
|
const password = passwordEl?.value || '';
|
||||||
|
const role = roleEl?.value || '';
|
||||||
|
|
||||||
|
if (!email || !password || !role) {
|
||||||
|
setStatusMessage('Please fill in all fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
operations['create-user'].run({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
role: parseInt(role, 10)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
openUploadModal: () => {
|
||||||
|
// This will be handled by the AdminContent component
|
||||||
|
// We'll need to pass a callback to open the modal
|
||||||
|
console.log('Upload modal should open');
|
||||||
|
},
|
||||||
|
openClearFolderModal: () => {
|
||||||
|
// This will be handled by the AdminContent component
|
||||||
|
// We'll need to pass a callback to open the modal
|
||||||
|
console.log('Clear folder modal should open');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
delete (window as any).adminDashboard;
|
||||||
|
};
|
||||||
|
}, [ensureAuth, operations, setStatusMessage]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div style={{ minHeight: '100vh', background: 'linear-gradient(180deg, #0b1022 0%, #0f172a 100%)', color: '#e5e7eb' }}>
|
||||||
|
<AdminHeader onLogout={handleLogout} />
|
||||||
|
|
||||||
|
<main style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '180px 1fr',
|
||||||
|
gap: '16px',
|
||||||
|
padding: '16px',
|
||||||
|
minHeight: 'calc(100vh - 80px)'
|
||||||
|
}}>
|
||||||
|
<AdminSidebar
|
||||||
|
operations={operations}
|
||||||
|
currentOpKey={currentOpKey}
|
||||||
|
onOpClick={handleOpClick}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AdminContent
|
||||||
|
currentOpKey={currentOpKey}
|
||||||
|
outputTitle={outputTitle}
|
||||||
|
outputMeta={outputMeta}
|
||||||
|
output={output}
|
||||||
|
status={status}
|
||||||
|
users={users}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onUserSearch={handleUserSearch}
|
||||||
|
api={api}
|
||||||
|
setStatusMessage={setStatusMessage}
|
||||||
|
setOutputMeta={setOutputMeta}
|
||||||
|
updateOutputTitle={updateOutputTitle}
|
||||||
|
onUserUpdate={handleUserUpdate}
|
||||||
|
operations={operations}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminDashboard;
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
interface AdminLoginProps {
|
||||||
|
onLoginSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminLogin: React.FC<AdminLoginProps> = ({ onLoginSuccess }) => {
|
||||||
|
const [email, setEmail] = useState<string>('');
|
||||||
|
const [password, setPassword] = useState<string>('');
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [error, setError] = useState<string>('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent): Promise<void> => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
let API_BASE = 'https://api.azaion.com';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/__server-info', { method: 'GET' });
|
||||||
|
if (res.ok) {
|
||||||
|
const info = await res.json();
|
||||||
|
if (info && info.proxyEnabled) {
|
||||||
|
API_BASE = '/proxy';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore; fall back to direct API_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE}/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
localStorage.setItem('authToken', data.token || data.data?.token);
|
||||||
|
localStorage.setItem('loginResponse', JSON.stringify(data));
|
||||||
|
onLoginSuccess();
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
setError(errorData.message || 'Login failed');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Network error. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
minHeight: '100vh',
|
||||||
|
background: 'linear-gradient(180deg, #0b1022 0%, #0f172a 100%)',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
background: '#1e293b',
|
||||||
|
padding: '2rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1)',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '400px'
|
||||||
|
}}>
|
||||||
|
<h2 style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: '1.5rem',
|
||||||
|
color: '#f8fafc'
|
||||||
|
}}>
|
||||||
|
Admin Login
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{ marginBottom: '1rem' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
color: '#cbd5e1'
|
||||||
|
}}>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '1px solid #475569',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: '#334155',
|
||||||
|
color: '#f8fafc',
|
||||||
|
fontSize: '1rem'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '0.5rem',
|
||||||
|
color: '#cbd5e1'
|
||||||
|
}}>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem',
|
||||||
|
border: '1px solid #475569',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: '#334155',
|
||||||
|
color: '#f8fafc',
|
||||||
|
fontSize: '1rem'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{
|
||||||
|
color: '#ef4444',
|
||||||
|
marginBottom: '1rem',
|
||||||
|
textAlign: 'center'
|
||||||
|
}}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
padding: '0.75rem',
|
||||||
|
background: isLoading ? '#64748b' : '#3b82f6',
|
||||||
|
color: '#ffffff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '1rem',
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'background-color 0.2s'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Logging in...' : 'Login'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminLogin;
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import UsersList from './UsersList.tsx';
|
||||||
|
import HardwareCharts from './HardwareCharts.tsx';
|
||||||
|
import ListResources from './ListResources.tsx';
|
||||||
|
import CreateUserModal from './CreateUserModal.tsx';
|
||||||
|
import UploadFileModal from './UploadFileModal.tsx';
|
||||||
|
import ClearFolderModal from './ClearFolderModal.tsx';
|
||||||
|
import NotificationBadge from './NotificationBadge.tsx';
|
||||||
|
import { User, ApiFunction, CreateUserFormData } from '../types';
|
||||||
|
|
||||||
|
interface AdminContentProps {
|
||||||
|
currentOpKey: string;
|
||||||
|
outputTitle: string;
|
||||||
|
outputMeta: string;
|
||||||
|
output: string;
|
||||||
|
status: string;
|
||||||
|
users: User[] | null;
|
||||||
|
searchValue: string;
|
||||||
|
onUserSearch: (searchEmail: string) => void;
|
||||||
|
api: ApiFunction;
|
||||||
|
setStatusMessage: (message: string) => void;
|
||||||
|
setOutputMeta: (meta: string) => void;
|
||||||
|
updateOutputTitle: (opKey: string, extra?: string) => void;
|
||||||
|
onUserUpdate?: () => void;
|
||||||
|
operations: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminContent: React.FC<AdminContentProps> = ({
|
||||||
|
currentOpKey,
|
||||||
|
outputTitle,
|
||||||
|
outputMeta,
|
||||||
|
output,
|
||||||
|
status,
|
||||||
|
users,
|
||||||
|
searchValue,
|
||||||
|
onUserSearch,
|
||||||
|
api,
|
||||||
|
setStatusMessage,
|
||||||
|
setOutputMeta,
|
||||||
|
updateOutputTitle,
|
||||||
|
onUserUpdate,
|
||||||
|
operations
|
||||||
|
}) => {
|
||||||
|
const [isCreateUserModalOpen, setIsCreateUserModalOpen] = useState(false);
|
||||||
|
const [isUploadFileModalOpen, setIsUploadFileModalOpen] = useState(false);
|
||||||
|
const [isClearFolderModalOpen, setIsClearFolderModalOpen] = useState(false);
|
||||||
|
const [localSearchValue, setLocalSearchValue] = useState(searchValue);
|
||||||
|
const [notification, setNotification] = useState<{
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
isVisible: boolean;
|
||||||
|
}>({
|
||||||
|
message: '',
|
||||||
|
type: 'success',
|
||||||
|
isVisible: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ref to store the current timeout
|
||||||
|
const timeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
|
// Update local search value when prop changes
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSearchValue(searchValue);
|
||||||
|
}, [searchValue]);
|
||||||
|
|
||||||
|
// Cleanup timeout on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle input change with throttling
|
||||||
|
const handleSearchChange = (value: string) => {
|
||||||
|
setLocalSearchValue(value);
|
||||||
|
|
||||||
|
// Clear existing timeout
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new timeout
|
||||||
|
timeoutRef.current = setTimeout(() => {
|
||||||
|
onUserSearch(value);
|
||||||
|
}, 700);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle Enter key press for immediate search
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
// Clear any pending timeout
|
||||||
|
if (timeoutRef.current) {
|
||||||
|
clearTimeout(timeoutRef.current);
|
||||||
|
timeoutRef.current = null;
|
||||||
|
}
|
||||||
|
onUserSearch(localSearchValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateUser = async (formData: CreateUserFormData) => {
|
||||||
|
// This function is called after successful user creation
|
||||||
|
// Refresh the user list to show the new user
|
||||||
|
console.log('Refreshing user list after creation');
|
||||||
|
onUserSearch(searchValue);
|
||||||
|
|
||||||
|
// Show success notification
|
||||||
|
setNotification({
|
||||||
|
message: `User "${formData.email}" has been created successfully!`,
|
||||||
|
type: 'success',
|
||||||
|
isVisible: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateUserError = (error: string) => {
|
||||||
|
// Show error notification
|
||||||
|
setNotification({
|
||||||
|
message: error,
|
||||||
|
type: 'error',
|
||||||
|
isVisible: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeNotification = () => {
|
||||||
|
setNotification(prev => ({ ...prev, isVisible: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle operation-specific modals
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentOpKey === 'upload-file') {
|
||||||
|
setIsUploadFileModalOpen(true);
|
||||||
|
} else if (currentOpKey === 'clear-folder') {
|
||||||
|
setIsClearFolderModalOpen(true);
|
||||||
|
}
|
||||||
|
}, [currentOpKey]);
|
||||||
|
|
||||||
|
const handleUploadFileSuccess = (message: string) => {
|
||||||
|
setNotification({
|
||||||
|
message,
|
||||||
|
type: 'success',
|
||||||
|
isVisible: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadFileError = (error: string) => {
|
||||||
|
setNotification({
|
||||||
|
message: error,
|
||||||
|
type: 'error',
|
||||||
|
isVisible: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFolderSuccess = (message: string) => {
|
||||||
|
setNotification({
|
||||||
|
message,
|
||||||
|
type: 'success',
|
||||||
|
isVisible: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearFolderError = (error: string) => {
|
||||||
|
setNotification({
|
||||||
|
message: error,
|
||||||
|
type: 'error',
|
||||||
|
isVisible: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<section style={{
|
||||||
|
border: '1px solid #1f2937',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'rgba(17, 24, 39, 0.7)',
|
||||||
|
padding: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '12px'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||||
|
<h3 style={{ margin: 0, fontSize: '20px' }}>{outputTitle}</h3>
|
||||||
|
{currentOpKey === 'list-users' && (
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="🔍 Search by email..."
|
||||||
|
value={localSearchValue}
|
||||||
|
onChange={(e) => handleSearchChange(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 16px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #374151',
|
||||||
|
background: '#1f2937',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: '14px',
|
||||||
|
width: '500px',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.border = '2px solid #6366f1';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.border = '2px solid #374151';
|
||||||
|
e.target.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{currentOpKey === 'list-users' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsCreateUserModalOpen(true)}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #059669',
|
||||||
|
background: 'linear-gradient(135deg, #10b981, #059669)',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxShadow: '0 2px 4px rgba(16, 185, 129, 0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(16, 185, 129, 0.4)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(0)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = '0 2px 4px rgba(16, 185, 129, 0.3)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '16px' }}>+</span>
|
||||||
|
Create User
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '14px' }}>{outputMeta}</div>
|
||||||
|
<div
|
||||||
|
className={`human-output ${currentOpKey === 'list-users' || currentOpKey === 'current-user' ? 'users-grid' : ''}`}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: currentOpKey === 'list-users' || currentOpKey === 'current-user'
|
||||||
|
? 'repeat(auto-fit, minmax(360px, 1fr))'
|
||||||
|
: 'repeat(auto-fill, minmax(360px, 1fr))',
|
||||||
|
gap: '24px',
|
||||||
|
marginTop: '16px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Render React components based on current operation */}
|
||||||
|
{currentOpKey === 'show-chart' && users && (
|
||||||
|
<HardwareCharts users={users} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(currentOpKey === 'list-users' || currentOpKey === 'current-user') && (
|
||||||
|
<UsersList
|
||||||
|
users={users}
|
||||||
|
onSearch={onUserSearch}
|
||||||
|
searchValue={localSearchValue}
|
||||||
|
api={api}
|
||||||
|
setStatusMessage={setStatusMessage}
|
||||||
|
onUserUpdate={onUserUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentOpKey === 'list-resources' && (
|
||||||
|
<ListResources
|
||||||
|
api={api}
|
||||||
|
setStatusMessage={setStatusMessage}
|
||||||
|
setOutputMeta={setOutputMeta}
|
||||||
|
updateOutputTitle={updateOutputTitle}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fallback for HTML content */}
|
||||||
|
{output && (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: output }} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{status && (
|
||||||
|
<div style={{
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '12px',
|
||||||
|
marginTop: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
background: '#0b1223',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}>
|
||||||
|
Status: {status}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create User Modal */}
|
||||||
|
<CreateUserModal
|
||||||
|
isOpen={isCreateUserModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCreateUserModalOpen(false);
|
||||||
|
// Reset form when closing
|
||||||
|
}}
|
||||||
|
onSubmit={handleCreateUser}
|
||||||
|
onError={handleCreateUserError}
|
||||||
|
api={api}
|
||||||
|
setStatusMessage={setStatusMessage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Upload File Modal */}
|
||||||
|
<UploadFileModal
|
||||||
|
isOpen={isUploadFileModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsUploadFileModalOpen(false);
|
||||||
|
// Switch back to list-users when closing
|
||||||
|
if (currentOpKey === 'upload-file') {
|
||||||
|
operations['list-users'].run();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSuccess={handleUploadFileSuccess}
|
||||||
|
onError={handleUploadFileError}
|
||||||
|
api={api}
|
||||||
|
setStatusMessage={setStatusMessage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Clear Folder Modal */}
|
||||||
|
<ClearFolderModal
|
||||||
|
isOpen={isClearFolderModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsClearFolderModalOpen(false);
|
||||||
|
// Switch back to list-users when closing
|
||||||
|
if (currentOpKey === 'clear-folder') {
|
||||||
|
operations['list-users'].run();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onSuccess={handleClearFolderSuccess}
|
||||||
|
onError={handleClearFolderError}
|
||||||
|
api={api}
|
||||||
|
setStatusMessage={setStatusMessage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Notification Badge */}
|
||||||
|
<NotificationBadge
|
||||||
|
message={notification.message}
|
||||||
|
type={notification.type}
|
||||||
|
isVisible={notification.isVisible}
|
||||||
|
onClose={closeNotification}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminContent;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface AdminHeaderProps {
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminHeader: React.FC<AdminHeaderProps> = ({ onLogout }) => {
|
||||||
|
return (
|
||||||
|
<header style={{
|
||||||
|
padding: '16px',
|
||||||
|
borderBottom: '1px solid #1f2937',
|
||||||
|
position: 'sticky' as const,
|
||||||
|
top: 0,
|
||||||
|
background: 'rgba(15, 23, 42, 0.85)',
|
||||||
|
backdropFilter: 'blur(6px)'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: '12px'
|
||||||
|
}}>
|
||||||
|
<h1 style={{ margin: '0', fontSize: '20px' }}>Azaion Admin</h1>
|
||||||
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
style={{
|
||||||
|
height: '36px',
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid #1f2937',
|
||||||
|
background: '#111827',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminHeader;
|
||||||
|
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { OPERATIONS_CONFIG } from '../config/constants.ts';
|
||||||
|
import { Operation } from '../types';
|
||||||
|
|
||||||
|
interface AdminSidebarProps {
|
||||||
|
operations: Record<string, Operation>;
|
||||||
|
currentOpKey: string;
|
||||||
|
onOpClick: (key: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AdminSidebar: React.FC<AdminSidebarProps> = ({ operations, currentOpKey, onOpClick }) => {
|
||||||
|
return (
|
||||||
|
<aside style={{
|
||||||
|
border: '1px solid #1f2937',
|
||||||
|
borderRadius: '12px',
|
||||||
|
background: 'rgba(17, 24, 39, 0.7)',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}>
|
||||||
|
<div style={{ maxHeight: 'calc(100vh - 260px)', overflow: 'auto' }}>
|
||||||
|
{Object.entries(operations).map(([key, op]) => {
|
||||||
|
const config = OPERATIONS_CONFIG[key] || {};
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
onClick={() => onOpClick(key)}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
padding: '10px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
background: currentOpKey === key ? 'rgba(99, 102, 241, 0.15)' : 'transparent',
|
||||||
|
borderLeft: currentOpKey === key ? '3px solid #6366f1' : '3px solid transparent',
|
||||||
|
borderBottom: 'none'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (currentOpKey !== key) {
|
||||||
|
(e.target as HTMLElement).style.background = 'rgba(99, 102, 241, 0.08)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.target as HTMLElement).style.background = currentOpKey === key ? 'rgba(99, 102, 241, 0.15)' : 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ color: '#cbd5e1', fontSize: '14px', fontWeight: '500' }}>
|
||||||
|
{config.title || op.title}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '13px' }}>
|
||||||
|
{config.description || ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminSidebar;
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ApiFunction } from '../types';
|
||||||
|
|
||||||
|
interface ClearFolderModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (message: string) => void;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
api: ApiFunction;
|
||||||
|
setStatusMessage: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ClearFolderModal: React.FC<ClearFolderModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
api,
|
||||||
|
setStatusMessage
|
||||||
|
}) => {
|
||||||
|
const [folderPath, setFolderPath] = useState<string>('');
|
||||||
|
const [isClearing, setIsClearing] = useState(false);
|
||||||
|
const [confirmText, setConfirmText] = useState<string>('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!folderPath.trim()) {
|
||||||
|
setStatusMessage('Please enter a folder path');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (confirmText !== 'CLEAR') {
|
||||||
|
setStatusMessage('Please type "CLEAR" to confirm');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsClearing(true);
|
||||||
|
setStatusMessage('Clearing folder...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await api('/clear-folder', {
|
||||||
|
method: 'POST',
|
||||||
|
json: { folderPath: folderPath.trim() }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Clear folder result:', result);
|
||||||
|
setStatusMessage('Folder cleared successfully!');
|
||||||
|
onSuccess(`Folder "${folderPath}" has been cleared successfully!`);
|
||||||
|
handleClose();
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Clear folder error:', e);
|
||||||
|
const errorMessage = `Failed to clear folder: ${e.message}`;
|
||||||
|
setStatusMessage(errorMessage);
|
||||||
|
onError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsClearing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setFolderPath('');
|
||||||
|
setConfirmText('');
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#1f2937',
|
||||||
|
border: '1px solid #374151',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
width: '90%',
|
||||||
|
maxWidth: '500px',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}}>
|
||||||
|
<h3 style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#ef4444'
|
||||||
|
}}>
|
||||||
|
⚠️ Clear Folder
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.target as HTMLElement).style.color = '#e5e7eb';
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = '#374151';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.target as HTMLElement).style.color = '#94a3b8';
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning Message */}
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#7f1d1d',
|
||||||
|
border: '1px solid #ef4444',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: '20px' }}>⚠️</span>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#ef4444', fontWeight: '600', fontSize: '14px' }}>
|
||||||
|
Warning: This action cannot be undone!
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#fca5a5', fontSize: '12px' }}>
|
||||||
|
All files in the specified folder will be permanently deleted.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
Folder Path *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={folderPath}
|
||||||
|
onChange={(e) => setFolderPath(e.target.value)}
|
||||||
|
placeholder="/uploads/documents"
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #374151',
|
||||||
|
background: '#111827',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.border = '2px solid #ef4444';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(239, 68, 68, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.border = '2px solid #374151';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
Type "CLEAR" to confirm *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={confirmText}
|
||||||
|
onChange={(e) => setConfirmText(e.target.value)}
|
||||||
|
placeholder="CLEAR"
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #374151',
|
||||||
|
background: '#111827',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.border = '2px solid #ef4444';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(239, 68, 68, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.border = '2px solid #374151';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isClearing}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #374151',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
cursor: isClearing ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
opacity: isClearing ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isClearing) {
|
||||||
|
(e.target as HTMLElement).style.border = '2px solid #6b7280';
|
||||||
|
(e.target as HTMLElement).style.color = '#e5e7eb';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isClearing) {
|
||||||
|
(e.target as HTMLElement).style.border = '2px solid #374151';
|
||||||
|
(e.target as HTMLElement).style.color = '#94a3b8';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isClearing || !folderPath.trim() || confirmText !== 'CLEAR'}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #dc2626',
|
||||||
|
background: isClearing ? '#4b5563' : 'linear-gradient(135deg, #ef4444, #dc2626)',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: (isClearing || !folderPath.trim() || confirmText !== 'CLEAR') ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
opacity: (isClearing || !folderPath.trim() || confirmText !== 'CLEAR') ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isClearing && folderPath.trim() && confirmText === 'CLEAR') {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(239, 68, 68, 0.4)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isClearing && folderPath.trim() && confirmText === 'CLEAR') {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(0)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = 'none';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isClearing ? 'Clearing...' : 'Clear Folder'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ClearFolderModal;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { ROLE_OPTIONS } from '../config/constants.ts';
|
||||||
|
import { CreateUserFormData, ApiFunction } from '../types';
|
||||||
|
|
||||||
|
interface CreateUserModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSubmit: (formData: CreateUserFormData) => void;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
api: ApiFunction;
|
||||||
|
setStatusMessage: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
onError,
|
||||||
|
api,
|
||||||
|
setStatusMessage
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<CreateUserFormData>({
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
role: 0 // Default to 0 (None role)
|
||||||
|
});
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.email || !formData.password) {
|
||||||
|
setStatusMessage('Please fill in all required fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setStatusMessage('Creating user...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert role to number before sending
|
||||||
|
const userData = {
|
||||||
|
...formData,
|
||||||
|
role: typeof formData.role === 'string' ? parseInt(formData.role, 10) : formData.role
|
||||||
|
};
|
||||||
|
|
||||||
|
// Make the API call directly
|
||||||
|
const result = await api('/users', { method: 'POST', json: userData });
|
||||||
|
console.log('User creation result:', result);
|
||||||
|
|
||||||
|
setStatusMessage('User created successfully!');
|
||||||
|
|
||||||
|
// Call the parent's onSubmit for any additional handling
|
||||||
|
await onSubmit(formData);
|
||||||
|
|
||||||
|
// Close modal immediately
|
||||||
|
handleClose();
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('User creation error:', e);
|
||||||
|
const errorMessage = `Failed to create user: ${e.message}`;
|
||||||
|
setStatusMessage(errorMessage);
|
||||||
|
onError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setFormData({ email: '', password: '', role: 0 });
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (field: keyof CreateUserFormData, value: string) => {
|
||||||
|
if (field === 'role') {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: parseInt(value, 10) || 0 }));
|
||||||
|
} else {
|
||||||
|
setFormData(prev => ({ ...prev, [field]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#1f2937',
|
||||||
|
border: '1px solid #374151',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
width: '90%',
|
||||||
|
maxWidth: '500px',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}}>
|
||||||
|
<h3 style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
Create New User
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.target as HTMLElement).style.color = '#e5e7eb';
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = '#374151';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.target as HTMLElement).style.color = '#94a3b8';
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
Email Address *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||||
|
placeholder="user@azaion.com"
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #374151',
|
||||||
|
background: '#111827',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.border = '2px solid #6366f1';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.border = '2px solid #374151';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
Password *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #374151',
|
||||||
|
background: '#111827',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.border = '2px solid #6366f1';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.border = '2px solid #374151';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.role}
|
||||||
|
onChange={(e) => handleInputChange('role', e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #374151',
|
||||||
|
background: '#111827',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.border = '2px solid #6366f1';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.border = '2px solid #374151';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ROLE_OPTIONS.map(option => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.text}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #374151',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
cursor: isSubmitting ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
opacity: isSubmitting ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
(e.target as HTMLElement).style.border = '2px solid #6b7280';
|
||||||
|
(e.target as HTMLElement).style.color = '#e5e7eb';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
(e.target as HTMLElement).style.border = '2px solid #374151';
|
||||||
|
(e.target as HTMLElement).style.color = '#94a3b8';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !formData.email || !formData.password}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #5154e6',
|
||||||
|
background: isSubmitting ? '#4b5563' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: (isSubmitting || !formData.email || !formData.password) ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
opacity: (isSubmitting || !formData.email || !formData.password) ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isSubmitting && formData.email && formData.password) {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isSubmitting && formData.email && formData.password) {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(0)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = 'none';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Creating...' : 'Create User'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateUserModal;
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { parseHardware } from '../utils/parsers.ts';
|
||||||
|
import { memoryToGBNumber } from '../utils/formatters.ts';
|
||||||
|
import { CHART_COLORS } from '../config/constants.ts';
|
||||||
|
import { User, ChartData, ChartSegment } from '../types';
|
||||||
|
|
||||||
|
interface HardwareChartsProps {
|
||||||
|
users: User[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PieChartProps {
|
||||||
|
counts: ChartData[];
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HardwareCharts: React.FC<HardwareChartsProps> = ({ users }) => {
|
||||||
|
const arr = Array.isArray(users) ? users : (users ? [users] : []);
|
||||||
|
if (!arr.length) {
|
||||||
|
return <div style={{ color: '#94a3b8', fontSize: '12px' }}>No users to chart.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate hardware data
|
||||||
|
const cpuMap = new Map<string, number>();
|
||||||
|
const gpuMap = new Map<string, number>();
|
||||||
|
const memMap = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const u of arr) {
|
||||||
|
const hw = parseHardware(u.hardware);
|
||||||
|
const cpu = (hw && hw.cpu ? String(hw.cpu).trim() : 'Unknown');
|
||||||
|
const gpu = (hw && hw.gpu ? String(hw.gpu).trim() : 'Unknown');
|
||||||
|
const memGb = hw && hw.memory ? memoryToGBNumber(hw.memory) : 0;
|
||||||
|
const memLabel = memGb ? `${Math.ceil(memGb)} GB` : 'Unknown';
|
||||||
|
|
||||||
|
cpuMap.set(cpu, (cpuMap.get(cpu) || 0) + 1);
|
||||||
|
gpuMap.set(gpu, (gpuMap.get(gpu) || 0) + 1);
|
||||||
|
memMap.set(memLabel, (memMap.get(memLabel) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTopCounts = (map: Map<string, number>): ChartData[] => {
|
||||||
|
const arr = Array.from(map.entries())
|
||||||
|
.map(([label, value]) => ({ label, value }))
|
||||||
|
.sort((a, b) => b.value - a.value);
|
||||||
|
|
||||||
|
const maxItems = 7;
|
||||||
|
if (arr.length <= maxItems) return arr;
|
||||||
|
|
||||||
|
const head = arr.slice(0, maxItems);
|
||||||
|
const rest = arr.slice(maxItems);
|
||||||
|
const restSum = rest.reduce((s, x) => s + x.value, 0);
|
||||||
|
|
||||||
|
return [...head, { label: 'Other', value: restSum }];
|
||||||
|
};
|
||||||
|
|
||||||
|
const cpuCounts = getTopCounts(cpuMap);
|
||||||
|
const gpuCounts = getTopCounts(gpuMap);
|
||||||
|
const memCounts = getTopCounts(memMap);
|
||||||
|
|
||||||
|
const PieChart: React.FC<PieChartProps> = ({ counts, title }) => {
|
||||||
|
const total = counts.reduce((s, c) => s + c.value, 0) || 1;
|
||||||
|
let acc = 0;
|
||||||
|
|
||||||
|
const segments: ChartSegment[] = counts.map((c, i) => {
|
||||||
|
const frac = c.value / total;
|
||||||
|
const start = acc;
|
||||||
|
acc += frac;
|
||||||
|
const color = CHART_COLORS[i % CHART_COLORS.length];
|
||||||
|
const startDeg = Math.round(start * 360);
|
||||||
|
const endDeg = Math.round(acc * 360);
|
||||||
|
return {
|
||||||
|
color,
|
||||||
|
startDeg,
|
||||||
|
endDeg,
|
||||||
|
label: c.label,
|
||||||
|
value: c.value,
|
||||||
|
percent: Math.round(frac * 1000) / 10
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const gradient = segments.map(s => `${s.color} ${s.startDeg}deg ${s.endDeg}deg`).join(', ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #1b2536',
|
||||||
|
background: '#0b1223',
|
||||||
|
borderRadius: '10px',
|
||||||
|
padding: '12px'
|
||||||
|
}}>
|
||||||
|
<h4 style={{ margin: '0 0 10px', color: '#e5e7eb' }}>{title}</h4>
|
||||||
|
<div style={{
|
||||||
|
width: '200px',
|
||||||
|
height: '200px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
margin: '10px auto',
|
||||||
|
background: `conic-gradient(${gradient})`
|
||||||
|
}}></div>
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: '8px 0 0', fontSize: '12px' }}>
|
||||||
|
{segments.map((s, i) => (
|
||||||
|
<li key={i} style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
marginBottom: '6px'
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
background: s.color,
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
display: 'inline-block',
|
||||||
|
border: '1px solid #1f2937'
|
||||||
|
}}></span>
|
||||||
|
<span style={{ color: '#e5e7eb' }}>{s.label}</span>
|
||||||
|
<span style={{ color: '#94a3b8', fontSize: '12px' }}>
|
||||||
|
— {s.value} ({s.percent}%)
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
gridColumn: '1 / -1'
|
||||||
|
}}>
|
||||||
|
<PieChart counts={cpuCounts} title="CPU Models" />
|
||||||
|
<PieChart counts={gpuCounts} title="GPU Models" />
|
||||||
|
<PieChart counts={memCounts} title="Memory (GB)" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HardwareCharts;
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { html, formatJSON } from '../utils/formatters.ts';
|
||||||
|
import { ApiFunction } from '../types';
|
||||||
|
|
||||||
|
interface ListResourcesProps {
|
||||||
|
api: ApiFunction;
|
||||||
|
setStatusMessage: (message: string) => void;
|
||||||
|
setOutputMeta: (meta: string) => void;
|
||||||
|
updateOutputTitle: (opKey: string, extra?: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceData {
|
||||||
|
error?: string;
|
||||||
|
data?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ListResources: React.FC<ListResourcesProps> = ({
|
||||||
|
api,
|
||||||
|
setStatusMessage,
|
||||||
|
setOutputMeta,
|
||||||
|
updateOutputTitle
|
||||||
|
}) => {
|
||||||
|
const [prodResources, setProdResources] = useState<any[] | ResourceData | null>(null);
|
||||||
|
const [stageResources, setStageResources] = useState<any[] | ResourceData | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const handleDownload = async (endpoint: string, envType: string) => {
|
||||||
|
try {
|
||||||
|
setStatusMessage(`Downloading installer for ${envType}...`);
|
||||||
|
setOutputMeta(`${new Date().toLocaleString()} — Downloading installer from ${endpoint}`);
|
||||||
|
|
||||||
|
// Get the API base URL to construct the full download URL
|
||||||
|
let API_BASE = 'https://api.azaion.com';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/__server-info', { method: 'GET' });
|
||||||
|
if (res.ok) {
|
||||||
|
const info = await res.json();
|
||||||
|
if (info && info.proxyEnabled) {
|
||||||
|
API_BASE = '/proxy';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore; fall back to direct API_BASE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get auth token
|
||||||
|
const AUTH_TOKEN = localStorage.getItem('authToken') || '';
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (AUTH_TOKEN) {
|
||||||
|
headers['Authorization'] = AUTH_TOKEN.startsWith('Bearer ') ? AUTH_TOKEN : `Bearer ${AUTH_TOKEN}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add auth header via fetch and create blob URL for secure download
|
||||||
|
const downloadUrl = `${API_BASE}${endpoint}`;
|
||||||
|
const response = await fetch(downloadUrl, { headers });
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract filename from Content-Disposition header if available
|
||||||
|
let filename = '';
|
||||||
|
const contentDisposition = response.headers.get('content-disposition');
|
||||||
|
if (contentDisposition) {
|
||||||
|
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
||||||
|
if (filenameMatch && filenameMatch[1]) {
|
||||||
|
filename = filenameMatch[1].replace(/['"]/g, '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await response.blob();
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Create a download link
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.style.display = 'none';
|
||||||
|
|
||||||
|
// Only set download attribute if we have a filename, otherwise let browser handle it
|
||||||
|
if (filename) {
|
||||||
|
link.download = filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
// Clean up blob URL
|
||||||
|
setTimeout(() => window.URL.revokeObjectURL(blobUrl), 100);
|
||||||
|
|
||||||
|
setStatusMessage(`Installer download started for ${envType}`);
|
||||||
|
setOutputMeta(`${new Date().toLocaleString()} — Installer download initiated from ${endpoint}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Download error:', error);
|
||||||
|
setStatusMessage(`Download failed: ${error.message}`);
|
||||||
|
setOutputMeta(`${new Date().toLocaleString()} — Download error: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateBoth = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
updateOutputTitle('list-resources');
|
||||||
|
setStatusMessage('Loading...');
|
||||||
|
setOutputMeta(`${new Date().toLocaleString()} — Loading resources from both environments...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load both environments in parallel
|
||||||
|
const [prodData, stageData] = await Promise.all([
|
||||||
|
api('/resources/list/suite', { method: 'GET' }).catch((e: any) => ({ error: e.message, data: e.data })),
|
||||||
|
api('/resources/list/suite-stage', { method: 'GET' }).catch((e: any) => ({ error: e.message, data: e.data }))
|
||||||
|
]);
|
||||||
|
|
||||||
|
setProdResources(prodData);
|
||||||
|
setStageResources(stageData);
|
||||||
|
setStatusMessage('OK');
|
||||||
|
setOutputMeta(`${new Date().toLocaleString()} — Resources loaded from both environments`);
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatusMessage(e.message);
|
||||||
|
setOutputMeta(`${new Date().toLocaleString()} — Error loading resources`);
|
||||||
|
setProdResources({ error: e.message, data: e.data });
|
||||||
|
setStageResources({ error: e.message, data: e.data });
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [api, updateOutputTitle, setStatusMessage, setOutputMeta, setProdResources, setStageResources]);
|
||||||
|
|
||||||
|
// Load resources on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
handleUpdateBoth();
|
||||||
|
}, [handleUpdateBoth]);
|
||||||
|
|
||||||
|
const renderResourceSection = (resources: any[] | ResourceData | null, title: string, envType: string) => {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #1b2536',
|
||||||
|
background: '#0b1223',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '16px',
|
||||||
|
flex: '1'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '12px'
|
||||||
|
}}>
|
||||||
|
<h4 style={{
|
||||||
|
margin: '0',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDownload(
|
||||||
|
envType === 'stage' ? '/resources/get-installer/stage' : '/resources/get-installer',
|
||||||
|
envType
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid #4f46e5',
|
||||||
|
background: 'linear-gradient(135deg, #4f46e5, #7c3aed)',
|
||||||
|
color: '#ffffff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxShadow: '0 2px 4px rgba(79, 70, 229, 0.3)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '6px'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(79, 70, 229, 0.4)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(0)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = '0 2px 4px rgba(79, 70, 229, 0.3)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M21 15V19C21 19.5304 20.7893 20.0391 20.4142 20.4142C20.0391 20.7893 19.5304 21 19 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V15" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M7 10L12 15L17 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M12 15V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
Download Installer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{resources ? (
|
||||||
|
(resources as ResourceData).error ? (
|
||||||
|
<pre style={{
|
||||||
|
background: '#0b1223',
|
||||||
|
border: '1px solid #0f1a33',
|
||||||
|
borderRadius: '8px',
|
||||||
|
padding: '10px',
|
||||||
|
maxHeight: '260px',
|
||||||
|
overflow: 'auto',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
{html(formatJSON((resources as ResourceData).data || (resources as ResourceData).error))}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
{Array.isArray(resources) ? (
|
||||||
|
resources.length > 0 ? (
|
||||||
|
<ul style={{
|
||||||
|
listStyle: 'none',
|
||||||
|
padding: 0,
|
||||||
|
margin: 0,
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflowWrap: 'anywhere' as const,
|
||||||
|
wordBreak: 'break-word' as const
|
||||||
|
}}>
|
||||||
|
{resources.map((item, index) => (
|
||||||
|
<li key={index} style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '1px solid #1b2536',
|
||||||
|
background: '#111827',
|
||||||
|
borderRadius: '6px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
maxWidth: '100%',
|
||||||
|
whiteSpace: 'normal' as const,
|
||||||
|
overflowWrap: 'anywhere' as const,
|
||||||
|
wordBreak: 'break-word' as const,
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '500',
|
||||||
|
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||||
|
}}>
|
||||||
|
{typeof item === 'string' ? item : JSON.stringify(item)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '16px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
padding: '20px'
|
||||||
|
}}>
|
||||||
|
No resources found.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '14px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
padding: '20px'
|
||||||
|
}}>
|
||||||
|
No resources found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '16px',
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
padding: '20px'
|
||||||
|
}}>
|
||||||
|
{isLoading ? 'Loading...' : 'Click Reload to load resources'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ gridColumn: '1 / -1' }}>
|
||||||
|
{/* Two-column layout for environments */}
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
gap: '20px',
|
||||||
|
marginBottom: '24px'
|
||||||
|
}}>
|
||||||
|
{renderResourceSection(prodResources, 'Prod Env', 'prod')}
|
||||||
|
{renderResourceSection(stageResources, 'Stage Env', 'stage')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reload Button underneath */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={handleUpdateBoth}
|
||||||
|
disabled={isLoading}
|
||||||
|
style={{
|
||||||
|
height: '48px',
|
||||||
|
padding: '0 32px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #5154e6',
|
||||||
|
background: 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||||
|
color: '#ffffff',
|
||||||
|
cursor: isLoading ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '16px',
|
||||||
|
fontWeight: '600',
|
||||||
|
opacity: isLoading ? 0.6 : 1,
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxShadow: '0 2px 4px rgba(99, 102, 241, 0.3)'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isLoading) {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(0)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = '0 2px 4px rgba(99, 102, 241, 0.3)';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? 'Reloading...' : 'Reload'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListResources;
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface NotificationBadgeProps {
|
||||||
|
message: string;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
isVisible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationBadge: React.FC<NotificationBadgeProps> = ({
|
||||||
|
message,
|
||||||
|
type,
|
||||||
|
isVisible,
|
||||||
|
onClose,
|
||||||
|
duration = 5000
|
||||||
|
}) => {
|
||||||
|
const [isAnimating, setIsAnimating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
setIsAnimating(true);
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsAnimating(false);
|
||||||
|
setTimeout(onClose, 300); // Wait for animation to complete
|
||||||
|
}, duration);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isVisible, duration, onClose]);
|
||||||
|
|
||||||
|
if (!isVisible) return null;
|
||||||
|
|
||||||
|
const isSuccess = type === 'success';
|
||||||
|
const bgColor = isSuccess ? '#065f46' : '#7f1d1d';
|
||||||
|
const borderColor = isSuccess ? '#10b981' : '#ef4444';
|
||||||
|
const textColor = isSuccess ? '#10b981' : '#ef4444';
|
||||||
|
const icon = isSuccess ? '✅' : '❌';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '20px',
|
||||||
|
right: '20px',
|
||||||
|
backgroundColor: bgColor,
|
||||||
|
border: `2px solid ${borderColor}`,
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '16px 20px',
|
||||||
|
minWidth: '300px',
|
||||||
|
maxWidth: '500px',
|
||||||
|
boxShadow: '0 10px 25px rgba(0, 0, 0, 0.3)',
|
||||||
|
zIndex: 10000,
|
||||||
|
transform: isAnimating ? 'translateX(0)' : 'translateX(100%)',
|
||||||
|
opacity: isAnimating ? 1 : 0,
|
||||||
|
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '12px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: '24px', flexShrink: 0 }}>{icon}</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{
|
||||||
|
color: textColor,
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: '16px',
|
||||||
|
marginBottom: '4px'
|
||||||
|
}}>
|
||||||
|
{isSuccess ? 'Success!' : 'Error!'}
|
||||||
|
</div>
|
||||||
|
<div style={{
|
||||||
|
color: isSuccess ? '#a7f3d0' : '#fca5a5',
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.4'
|
||||||
|
}}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: isSuccess ? '#a7f3d0' : '#fca5a5',
|
||||||
|
fontSize: '20px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
flexShrink: 0
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = 'rgba(255, 255, 255, 0.1)';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationBadge;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { ApiFunction } from '../types';
|
||||||
|
|
||||||
|
interface UploadFileModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (message: string) => void;
|
||||||
|
onError: (error: string) => void;
|
||||||
|
api: ApiFunction;
|
||||||
|
setStatusMessage: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UploadFileModal: React.FC<UploadFileModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
onError,
|
||||||
|
api,
|
||||||
|
setStatusMessage
|
||||||
|
}) => {
|
||||||
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||||
|
const [folderPath, setFolderPath] = useState<string>('');
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
setSelectedFile(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!selectedFile) {
|
||||||
|
setStatusMessage('Please select a file to upload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!folderPath.trim()) {
|
||||||
|
setStatusMessage('Please enter a folder path');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
setStatusMessage('Uploading file...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
formData.append('folderPath', folderPath);
|
||||||
|
|
||||||
|
const result = await api('/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
formData: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Upload result:', result);
|
||||||
|
setStatusMessage('File uploaded successfully!');
|
||||||
|
onSuccess(`File "${selectedFile.name}" uploaded to "${folderPath}" successfully!`);
|
||||||
|
handleClose();
|
||||||
|
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Upload error:', e);
|
||||||
|
const errorMessage = `Failed to upload file: ${e.message}`;
|
||||||
|
setStatusMessage(errorMessage);
|
||||||
|
onError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setSelectedFile(null);
|
||||||
|
setFolderPath('');
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1000
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#1f2937',
|
||||||
|
border: '1px solid #374151',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '24px',
|
||||||
|
width: '90%',
|
||||||
|
maxWidth: '500px',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
overflow: 'auto'
|
||||||
|
}}>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: '20px'
|
||||||
|
}}>
|
||||||
|
<h3 style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '20px',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
Upload File
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '24px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '4px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
transition: 'all 0.2s ease'
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.target as HTMLElement).style.color = '#e5e7eb';
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = '#374151';
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.target as HTMLElement).style.color = '#94a3b8';
|
||||||
|
(e.target as HTMLElement).style.backgroundColor = 'transparent';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
Select File *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileSelect}
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #374151',
|
||||||
|
background: '#111827',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.border = '2px solid #6366f1';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.border = '2px solid #374151';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{selectedFile && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
backgroundColor: '#065f46',
|
||||||
|
border: '1px solid #10b981',
|
||||||
|
borderRadius: '6px',
|
||||||
|
color: '#a7f3d0',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}>
|
||||||
|
Selected: {selectedFile.name} ({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '24px' }}>
|
||||||
|
<label style={{
|
||||||
|
display: 'block',
|
||||||
|
marginBottom: '6px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '#e5e7eb'
|
||||||
|
}}>
|
||||||
|
Folder Path *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={folderPath}
|
||||||
|
onChange={(e) => setFolderPath(e.target.value)}
|
||||||
|
placeholder="/uploads/documents"
|
||||||
|
required
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #374151',
|
||||||
|
background: '#111827',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontSize: '14px',
|
||||||
|
outline: 'none',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
boxSizing: 'border-box'
|
||||||
|
}}
|
||||||
|
onFocus={(e) => {
|
||||||
|
e.target.style.border = '2px solid #6366f1';
|
||||||
|
e.target.style.boxShadow = '0 0 0 3px rgba(99, 102, 241, 0.1)';
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
e.target.style.border = '2px solid #374151';
|
||||||
|
e.target.style.boxShadow = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={isUploading}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #374151',
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#94a3b8',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
cursor: isUploading ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
opacity: isUploading ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isUploading) {
|
||||||
|
(e.target as HTMLElement).style.border = '2px solid #6b7280';
|
||||||
|
(e.target as HTMLElement).style.color = '#e5e7eb';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isUploading) {
|
||||||
|
(e.target as HTMLElement).style.border = '2px solid #374151';
|
||||||
|
(e.target as HTMLElement).style.color = '#94a3b8';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isUploading || !selectedFile || !folderPath.trim()}
|
||||||
|
style={{
|
||||||
|
height: '40px',
|
||||||
|
padding: '0 20px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '2px solid #5154e6',
|
||||||
|
background: isUploading ? '#4b5563' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||||
|
color: '#ffffff',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '600',
|
||||||
|
cursor: (isUploading || !selectedFile || !folderPath.trim()) ? 'not-allowed' : 'pointer',
|
||||||
|
transition: 'all 0.2s ease',
|
||||||
|
opacity: (isUploading || !selectedFile || !folderPath.trim()) ? 0.6 : 1
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!isUploading && selectedFile && folderPath.trim()) {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(-1px)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = '0 4px 8px rgba(99, 102, 241, 0.4)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
if (!isUploading && selectedFile && folderPath.trim()) {
|
||||||
|
(e.target as HTMLElement).style.transform = 'translateY(0)';
|
||||||
|
(e.target as HTMLElement).style.boxShadow = 'none';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isUploading ? 'Uploading...' : 'Upload File'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UploadFileModal;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { parseHardware, roleCode, roleLabel, getLastLoginValue } from '../utils/parsers.ts';
|
||||||
|
import { formatMemoryGB, formatUTCDate } from '../utils/formatters.ts';
|
||||||
|
import { User, ApiFunction } from '../types';
|
||||||
|
|
||||||
|
interface UsersListProps {
|
||||||
|
users: User[] | null;
|
||||||
|
onSearch: (searchEmail: string) => void;
|
||||||
|
searchValue: string;
|
||||||
|
api: ApiFunction;
|
||||||
|
setStatusMessage: (message: string) => void;
|
||||||
|
onUserUpdate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UsersList: React.FC<UsersListProps> = ({
|
||||||
|
users,
|
||||||
|
onSearch,
|
||||||
|
searchValue,
|
||||||
|
api,
|
||||||
|
setStatusMessage,
|
||||||
|
onUserUpdate
|
||||||
|
}) => {
|
||||||
|
|
||||||
|
const handleToggleUser = async (email: string, isEnabled: boolean) => {
|
||||||
|
const action = isEnabled ? 'disable' : 'enable';
|
||||||
|
setStatusMessage(`${action === 'enable' ? 'Enabling' : 'Disabling'} user...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api(`/users/${encodeURIComponent(email)}/${action}`, { method: 'PUT' });
|
||||||
|
setStatusMessage(`User ${action}d successfully`);
|
||||||
|
if (onUserUpdate) onUserUpdate();
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatusMessage(`Failed to ${action} user: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (email: string) => {
|
||||||
|
if (!window.confirm(`Are you sure you want to delete user "${email}"? This action cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatusMessage('Deleting user...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api(`/users/${encodeURIComponent(email)}`, { method: 'DELETE' });
|
||||||
|
setStatusMessage('User deleted successfully');
|
||||||
|
if (onUserUpdate) onUserUpdate();
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatusMessage(`Failed to delete user: ${e.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const arr = Array.isArray(users) ? users : (users ? [users] : []);
|
||||||
|
if (!arr.length) {
|
||||||
|
return <div style={{ color: '#94a3b8', fontSize: '12px' }}>No users found.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort so role 10 users go first
|
||||||
|
if (arr.length > 1) {
|
||||||
|
arr.sort((a, b) => {
|
||||||
|
const a10 = roleCode(a && a.role) === 10;
|
||||||
|
const b10 = roleCode(b && b.role) === 10;
|
||||||
|
if (a10 && !b10) return -1;
|
||||||
|
if (b10 && !a10) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{arr.map(u => {
|
||||||
|
const { text: rText, cls: rCls } = roleLabel(u.role);
|
||||||
|
const hw = parseHardware(u.hardware);
|
||||||
|
const lastLoginRaw = getLastLoginValue(u);
|
||||||
|
const lastLoginDisplay = (lastLoginRaw != null && String(lastLoginRaw).trim() !== '')
|
||||||
|
? formatUTCDate(lastLoginRaw)
|
||||||
|
: 'Unknown';
|
||||||
|
|
||||||
|
// Get queue offset (only the first one)
|
||||||
|
const qo = (u.userConfig && u.userConfig.queueOffsets) || {};
|
||||||
|
const queueOffset = qo.annotationsOffset ?? '';
|
||||||
|
const isEnabled = u.isEnabled !== false; // Default to enabled if property is missing
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={u.id} className="card" style={{
|
||||||
|
padding: '16px',
|
||||||
|
minHeight: '200px',
|
||||||
|
opacity: isEnabled ? 1 : 0.6,
|
||||||
|
filter: isEnabled ? 'none' : 'grayscale(0.3)',
|
||||||
|
border: isEnabled ? '1px solid #2a3b5f' : '1px solid #374151'
|
||||||
|
}}>
|
||||||
|
{/* Header with email and badges */}
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<h4 style={{ margin: '0 0 8px', fontSize: '20px', fontWeight: 700, color: '#93c5fd' }}>
|
||||||
|
{u.email || 'User'}
|
||||||
|
</h4>
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap' as const
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
{rText && (
|
||||||
|
<span className={`badge ${rCls}`} style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: '999px',
|
||||||
|
fontSize: '14px',
|
||||||
|
border: '1px solid #2b3650',
|
||||||
|
background: 'rgba(99, 102, 241, 0.18)',
|
||||||
|
color: '#c7d2fe',
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
{rText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span style={{
|
||||||
|
display: 'inline-block',
|
||||||
|
padding: '6px 10px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '11px',
|
||||||
|
border: '1px solid #4b5563',
|
||||||
|
background: '#374151',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
whiteSpace: 'nowrap' as const,
|
||||||
|
fontWeight: '500'
|
||||||
|
}}>
|
||||||
|
Last Login: {lastLoginDisplay}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Queue Panel */}
|
||||||
|
{queueOffset && (
|
||||||
|
<div style={{
|
||||||
|
background: '#0b1223',
|
||||||
|
border: '1px solid #1b2536',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '12px',
|
||||||
|
marginBottom: '8px',
|
||||||
|
minHeight: '60px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end'
|
||||||
|
}}>
|
||||||
|
<div>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '4px' }}>Queue Offset</div>
|
||||||
|
<div style={{ fontSize: '18px', fontWeight: '600', color: '#e5e7eb' }}>{queueOffset}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn-small"
|
||||||
|
style={{
|
||||||
|
height: '48px',
|
||||||
|
fontSize: '14px',
|
||||||
|
padding: '10px 18px',
|
||||||
|
background: '#6366f1',
|
||||||
|
border: '1px solid #5154e6',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: '1.3',
|
||||||
|
whiteSpace: 'normal' as const,
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
width: '80px',
|
||||||
|
fontWeight: '500',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Set Offset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hardware Panel */}
|
||||||
|
{hw && (
|
||||||
|
<div style={{
|
||||||
|
background: '#0b1223',
|
||||||
|
border: '1px solid #1b2536',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '12px',
|
||||||
|
minHeight: '80px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end'
|
||||||
|
}}>
|
||||||
|
<div style={{ flex: '1' }}>
|
||||||
|
<div style={{ color: '#94a3b8', fontSize: '13px', marginBottom: '6px' }}>Hardware</div>
|
||||||
|
<div style={{ fontSize: '13px', lineHeight: '1.4' }}>
|
||||||
|
{hw.cpu && <div style={{ marginBottom: '3px' }}>{hw.cpu}</div>}
|
||||||
|
{hw.gpu && (
|
||||||
|
<div style={{
|
||||||
|
color: hw.gpu.toLowerCase().includes('nvidia') ? '#86efac' :
|
||||||
|
hw.gpu.toLowerCase().includes('amd') || hw.gpu.toLowerCase().includes('radeon') ? '#fca5a5' : '#c7d2fe',
|
||||||
|
fontWeight: hw.gpu.toLowerCase().includes('nvidia') || hw.gpu.toLowerCase().includes('amd') ? 600 : 'normal',
|
||||||
|
marginBottom: '3px'
|
||||||
|
}}>
|
||||||
|
{hw.gpu}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hw.memory && <div>{formatMemoryGB(hw.memory)}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {/* Reset Hardware functionality */}}
|
||||||
|
className="btn-small"
|
||||||
|
style={{
|
||||||
|
height: '48px',
|
||||||
|
fontSize: '14px',
|
||||||
|
padding: '10px 18px',
|
||||||
|
background: '#991b1b',
|
||||||
|
border: '1px solid #b91c1c',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
marginLeft: '12px',
|
||||||
|
lineHeight: '1.3',
|
||||||
|
whiteSpace: 'normal' as const,
|
||||||
|
textAlign: 'center' as const,
|
||||||
|
width: '80px',
|
||||||
|
fontWeight: '500',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Hardware
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User Action Buttons */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: '10px',
|
||||||
|
marginTop: '4px',
|
||||||
|
paddingTop: '4px',
|
||||||
|
borderTop: '1px solid #1b2536',
|
||||||
|
justifyContent: 'flex-end'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleUser(u.email, isEnabled)}
|
||||||
|
style={{
|
||||||
|
height: '30px',
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: isEnabled ? '#92400e' : '#166534',
|
||||||
|
border: isEnabled ? '1px solid #a16207' : '1px solid #15803d',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: '1.3',
|
||||||
|
fontWeight: '500',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isEnabled ? 'Disable' : 'Enable'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteUser(u.email)}
|
||||||
|
style={{
|
||||||
|
height: '30px',
|
||||||
|
fontSize: '12px',
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: '#7f1d1d',
|
||||||
|
border: '1px solid #991b1b',
|
||||||
|
color: '#e5e7eb',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
lineHeight: '1.3',
|
||||||
|
fontWeight: '500',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UsersList;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
// Static configurations for the admin dashboard
|
||||||
|
import { RoleInfo, OperationConfig, RoleOption } from '../types';
|
||||||
|
|
||||||
|
export const ROLES: Record<number, RoleInfo> = {
|
||||||
|
1000: { text: 'ApiAdmin', cls: 'apiadmin' },
|
||||||
|
40: { text: 'Admin', cls: 'admin' },
|
||||||
|
50: { text: 'ResourceUploader', cls: 'uploader' },
|
||||||
|
30: { text: 'CompanionPC', cls: 'companion' },
|
||||||
|
20: { text: 'Validator', cls: 'validator' },
|
||||||
|
10: { text: 'Operator', cls: 'operator' },
|
||||||
|
0: { text: 'None', cls: 'none' }
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_OPTIONS: RoleOption[] = [
|
||||||
|
{ value: '', text: 'Choose Role' },
|
||||||
|
{ value: '10', text: '10 (Operator)' },
|
||||||
|
{ value: '20', text: '20 (Validator)' },
|
||||||
|
{ value: '30', text: '30 (CompanionPC)' },
|
||||||
|
{ value: '40', text: '40 (Admin)' },
|
||||||
|
{ value: '50', text: '50 (ResourceUploader)' },
|
||||||
|
{ value: '1000', text: '1000 (ApiAdmin)' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OPERATIONS_CONFIG: Record<string, OperationConfig> = {
|
||||||
|
'list-users': {
|
||||||
|
title: 'List Users',
|
||||||
|
description: 'Filter by email (optional)',
|
||||||
|
hasForm: false
|
||||||
|
},
|
||||||
|
'show-chart': {
|
||||||
|
title: 'Show Chart',
|
||||||
|
description: 'Pie charts by CPU / GPU / Memory',
|
||||||
|
hasForm: false
|
||||||
|
},
|
||||||
|
'current-user': {
|
||||||
|
title: 'Current User',
|
||||||
|
description: 'Get info about current user',
|
||||||
|
hasForm: false
|
||||||
|
},
|
||||||
|
'list-resources': {
|
||||||
|
title: 'List Resources',
|
||||||
|
description: 'List files in folder',
|
||||||
|
hasForm: false
|
||||||
|
},
|
||||||
|
'upload-file': {
|
||||||
|
title: 'Upload File',
|
||||||
|
description: 'To specific folder',
|
||||||
|
hasForm: true
|
||||||
|
},
|
||||||
|
'clear-folder': {
|
||||||
|
title: 'Clear Folder',
|
||||||
|
description: 'Remove all files',
|
||||||
|
hasForm: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OUTPUT_TITLES: Record<string, string> = {
|
||||||
|
'list-users': 'Users',
|
||||||
|
'current-user': 'Current User',
|
||||||
|
'list-resources': 'Resources',
|
||||||
|
'show-chart': 'Users Hardware charts',
|
||||||
|
'upload-file': 'Upload File',
|
||||||
|
'clear-folder': 'Clear Folder',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CHART_COLORS: string[] = [
|
||||||
|
'#60a5fa', '#34d399', '#f472b6', '#fbbf24', '#a78bfa',
|
||||||
|
'#f87171', '#22d3ee', '#86efac', '#fca5a5', '#c084fc'
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MEMORY_THRESHOLDS = {
|
||||||
|
VERY_LARGE: 1e7, // Likely KB
|
||||||
|
LARGE: 1e5, // Likely MB
|
||||||
|
MEDIUM: 256 // Likely already GB
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const LOGIN_FIELDS: string[] = [
|
||||||
|
'lastLogin', 'last_login', 'lastLoginAt', 'last_login_at',
|
||||||
|
'lastSeen', 'last_seen', 'lastSeenAt', 'last_seen_at',
|
||||||
|
'last_activity', 'lastActivity'
|
||||||
|
];
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { useState, useCallback, useMemo } from 'react';
|
||||||
|
import { OUTPUT_TITLES, OPERATIONS_CONFIG } from '../config/constants.ts';
|
||||||
|
import {
|
||||||
|
User,
|
||||||
|
UseAdminOperationsReturn,
|
||||||
|
ApiFunction,
|
||||||
|
ServerInfo
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const useAdminOperations = (): UseAdminOperationsReturn => {
|
||||||
|
const [currentOpKey, setCurrentOpKey] = useState<string>('list-users');
|
||||||
|
const [outputTitle, setOutputTitle] = useState<string>('Users');
|
||||||
|
const [outputMeta, setOutputMeta] = useState<string>('');
|
||||||
|
const [output, setOutput] = useState<string>('');
|
||||||
|
const [status, setStatus] = useState<string>('');
|
||||||
|
const [users, setUsers] = useState<User[] | null>(null);
|
||||||
|
const [searchValue, setSearchValue] = useState<string>('');
|
||||||
|
|
||||||
|
let API_BASE = 'https://api.azaion.com';
|
||||||
|
let apiResolved = false;
|
||||||
|
let AUTH_TOKEN = localStorage.getItem('authToken') || '';
|
||||||
|
|
||||||
|
const resolveApiBase = async (): Promise<string> => {
|
||||||
|
if (apiResolved) return API_BASE;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/__server-info', { method: 'GET' });
|
||||||
|
if (res.ok) {
|
||||||
|
const info: ServerInfo = await res.json();
|
||||||
|
if (info && info.proxyEnabled) API_BASE = '/proxy';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore; use default API_BASE
|
||||||
|
}
|
||||||
|
apiResolved = true;
|
||||||
|
return API_BASE;
|
||||||
|
};
|
||||||
|
|
||||||
|
const api: ApiFunction = async (path, { method = 'GET', json, formData, headers = {} } = {}) => {
|
||||||
|
await resolveApiBase();
|
||||||
|
const h: Record<string, string> = { ...headers };
|
||||||
|
if (AUTH_TOKEN) h['Authorization'] = AUTH_TOKEN.startsWith('Bearer ') ? AUTH_TOKEN : `Bearer ${AUTH_TOKEN}`;
|
||||||
|
|
||||||
|
let body: string | FormData | undefined;
|
||||||
|
if (json !== undefined) {
|
||||||
|
h['Content-Type'] = 'application/json';
|
||||||
|
body = JSON.stringify(json);
|
||||||
|
} else if (formData) {
|
||||||
|
body = formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, { method, headers: h, body });
|
||||||
|
const text = await res.text();
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = new Error(`HTTP ${res.status}`) as any;
|
||||||
|
err.data = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOutputTitle = useCallback((opKey: string, extra?: string) => {
|
||||||
|
let title = OUTPUT_TITLES[opKey] || 'Output';
|
||||||
|
if (extra) title = `${title} — ${extra}`;
|
||||||
|
setOutputTitle(title);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setStatusMessage = useCallback((text: string, type: string = '') => {
|
||||||
|
setStatus(text || '');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Operations definition
|
||||||
|
const operations = useMemo(() => ({
|
||||||
|
'list-users': {
|
||||||
|
...OPERATIONS_CONFIG['list-users'],
|
||||||
|
run: async (searchEmail: string = '') => {
|
||||||
|
setCurrentOpKey('list-users');
|
||||||
|
updateOutputTitle('list-users');
|
||||||
|
|
||||||
|
const qs = searchEmail ? `?searchEmail=${encodeURIComponent(searchEmail)}` : '';
|
||||||
|
setStatusMessage('Loading...');
|
||||||
|
try {
|
||||||
|
const data: User[] = await api(`/users${qs}`, { method: 'GET' });
|
||||||
|
setStatusMessage('OK');
|
||||||
|
setUsers(data);
|
||||||
|
setOutput('');
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatusMessage(e.message);
|
||||||
|
setUsers([]);
|
||||||
|
setOutput(`<div class="small-muted">Error: ${e.message}</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'show-chart': {
|
||||||
|
...OPERATIONS_CONFIG['show-chart'],
|
||||||
|
run: async () => {
|
||||||
|
setCurrentOpKey('show-chart');
|
||||||
|
updateOutputTitle('show-chart');
|
||||||
|
setStatusMessage('Loading users...');
|
||||||
|
try {
|
||||||
|
const data: User[] = await api(`/users`, { method: 'GET' });
|
||||||
|
setStatusMessage('OK');
|
||||||
|
setUsers(data);
|
||||||
|
setOutput('');
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatusMessage(e.message);
|
||||||
|
setUsers([]);
|
||||||
|
setOutput(`<div class="small-muted">Error: ${e.message}</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'current-user': {
|
||||||
|
...OPERATIONS_CONFIG['current-user'],
|
||||||
|
run: async () => {
|
||||||
|
setCurrentOpKey('current-user');
|
||||||
|
updateOutputTitle('current-user');
|
||||||
|
setStatusMessage('Loading...');
|
||||||
|
try {
|
||||||
|
const data: User = await api('/currentuser', { method: 'GET' });
|
||||||
|
setStatusMessage('OK');
|
||||||
|
setUsers([data]);
|
||||||
|
setOutput('');
|
||||||
|
} catch (e: any) {
|
||||||
|
setStatusMessage(e.message);
|
||||||
|
setUsers([]);
|
||||||
|
setOutput(`<div class="small-muted">Error: ${e.message}</div>`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'list-resources': {
|
||||||
|
...OPERATIONS_CONFIG['list-resources'],
|
||||||
|
run: async () => {
|
||||||
|
setCurrentOpKey('list-resources');
|
||||||
|
updateOutputTitle('list-resources');
|
||||||
|
setUsers(null);
|
||||||
|
setOutput('');
|
||||||
|
setOutputMeta(`${new Date().toLocaleString()}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'upload-file': {
|
||||||
|
...OPERATIONS_CONFIG['upload-file'],
|
||||||
|
run: async () => {
|
||||||
|
setCurrentOpKey('upload-file');
|
||||||
|
updateOutputTitle('upload-file');
|
||||||
|
setUsers(null);
|
||||||
|
setOutput(`
|
||||||
|
<div class="card full-span">
|
||||||
|
<h4>Upload File</h4>
|
||||||
|
<div style="margin-top: 16px; text-align: center;">
|
||||||
|
<p style="color: #94a3b8; margin-bottom: 20px;">
|
||||||
|
Click the "Upload File" button to open the upload dialog.
|
||||||
|
</p>
|
||||||
|
<button onclick="window.adminDashboard.openUploadModal()" style="
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #5154e6;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
">
|
||||||
|
📁 Upload File
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'clear-folder': {
|
||||||
|
...OPERATIONS_CONFIG['clear-folder'],
|
||||||
|
run: async () => {
|
||||||
|
setCurrentOpKey('clear-folder');
|
||||||
|
updateOutputTitle('clear-folder');
|
||||||
|
setUsers(null);
|
||||||
|
setOutput(`
|
||||||
|
<div class="card full-span">
|
||||||
|
<h4 style="color: #ef4444;">⚠️ Clear Folder</h4>
|
||||||
|
<div style="margin-top: 16px; text-align: center;">
|
||||||
|
<p style="color: #94a3b8; margin-bottom: 20px;">
|
||||||
|
Click the "Clear Folder" button to open the clear folder dialog.
|
||||||
|
<br><strong style="color: #ef4444;">Warning: This action cannot be undone!</strong>
|
||||||
|
</p>
|
||||||
|
<button onclick="window.adminDashboard.openClearFolderModal()" style="
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid #dc2626;
|
||||||
|
background: linear-gradient(135deg, #ef4444, #dc2626);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
">
|
||||||
|
🗑️ Clear Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}), [api, setCurrentOpKey, updateOutputTitle, setStatusMessage, setOutputMeta, setUsers, setSearchValue]);
|
||||||
|
|
||||||
|
const handleUserSearch = useCallback(async (searchEmail: string) => {
|
||||||
|
setSearchValue(searchEmail);
|
||||||
|
await operations['list-users'].run(searchEmail);
|
||||||
|
}, [operations, setSearchValue]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentOpKey,
|
||||||
|
outputTitle,
|
||||||
|
outputMeta,
|
||||||
|
output,
|
||||||
|
status,
|
||||||
|
users,
|
||||||
|
searchValue,
|
||||||
|
operations,
|
||||||
|
api,
|
||||||
|
setStatusMessage,
|
||||||
|
setOutputMeta,
|
||||||
|
updateOutputTitle,
|
||||||
|
handleUserSearch
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useAdminOperations;
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
// Type definitions for the Admin dashboard
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: number;
|
||||||
|
hardware?: string | HardwareParsed;
|
||||||
|
lastLogin?: string | number;
|
||||||
|
last_login?: string | number;
|
||||||
|
lastLoginAt?: string | number;
|
||||||
|
last_login_at?: string | number;
|
||||||
|
lastSeen?: string | number;
|
||||||
|
last_seen?: string | number;
|
||||||
|
lastSeenAt?: string | number;
|
||||||
|
last_seen_at?: string | number;
|
||||||
|
last_activity?: string | number;
|
||||||
|
lastActivity?: string | number;
|
||||||
|
userConfig?: {
|
||||||
|
queueOffsets?: {
|
||||||
|
annotationsOffset?: number;
|
||||||
|
annotationsConfirmOffset?: number;
|
||||||
|
annotationsCommandsOffset?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
isEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HardwareParsed {
|
||||||
|
cpu: string;
|
||||||
|
gpu: string;
|
||||||
|
memory: string;
|
||||||
|
drive: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleInfo {
|
||||||
|
text: string;
|
||||||
|
cls: string;
|
||||||
|
code?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationConfig {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
hasForm: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartData {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChartSegment extends ChartData {
|
||||||
|
color: string;
|
||||||
|
startDeg: number;
|
||||||
|
endDeg: number;
|
||||||
|
percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoleOption {
|
||||||
|
value: string;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
id?: string;
|
||||||
|
userId?: string;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token?: string;
|
||||||
|
accessToken?: string;
|
||||||
|
access_token?: string;
|
||||||
|
jwt?: string;
|
||||||
|
Authorization?: string;
|
||||||
|
authorization?: string;
|
||||||
|
authToken?: string;
|
||||||
|
data?: {
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServerInfo {
|
||||||
|
proxyEnabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserFormData {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
role: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueueOffsets {
|
||||||
|
annotationsOffset: number;
|
||||||
|
annotationsConfirmOffset: number;
|
||||||
|
annotationsCommandsOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Operation {
|
||||||
|
title: string;
|
||||||
|
hasForm: boolean;
|
||||||
|
run: (formData?: any) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAdminOperationsReturn {
|
||||||
|
currentOpKey: string;
|
||||||
|
outputTitle: string;
|
||||||
|
outputMeta: string;
|
||||||
|
output: string;
|
||||||
|
status: string;
|
||||||
|
users: User[] | null;
|
||||||
|
searchValue: string;
|
||||||
|
operations: Record<string, Operation>;
|
||||||
|
api: ApiFunction;
|
||||||
|
setStatusMessage: (text: string, type?: string) => void;
|
||||||
|
setOutputMeta: (meta: string) => void;
|
||||||
|
updateOutputTitle: (opKey: string, extra?: string) => void;
|
||||||
|
handleUserSearch: (searchEmail: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiFunction {
|
||||||
|
(path: string, options?: {
|
||||||
|
method?: string;
|
||||||
|
json?: any;
|
||||||
|
formData?: FormData;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}): Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MemoryThreshold = 'VERY_LARGE' | 'LARGE' | 'MEDIUM';
|
||||||
|
|
||||||
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||||
|
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Formatting utilities for the admin dashboard
|
||||||
|
import { MEMORY_THRESHOLDS } from '../config/constants.ts';
|
||||||
|
|
||||||
|
export function html(str: string | number): string {
|
||||||
|
return String(str).replace(/[&<>]/g, s => ({
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>'
|
||||||
|
}[s] || s));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatJSON(obj: any): string {
|
||||||
|
try {
|
||||||
|
if (typeof obj === 'string') obj = JSON.parse(obj);
|
||||||
|
return JSON.stringify(obj, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMemoryGB(mem: string | number | null | undefined): string {
|
||||||
|
if (mem == null) return '';
|
||||||
|
let raw = String(mem).trim();
|
||||||
|
|
||||||
|
// Extract digits if memory was part of a sentence
|
||||||
|
const digits = raw.match(/\d+(?:[.,]\d+)?/g);
|
||||||
|
if (digits && digits.length) raw = digits[0].replace(',', '.');
|
||||||
|
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!isFinite(n) || n <= 0) return String(mem);
|
||||||
|
|
||||||
|
// Heuristics: typical API returns KB (e.g., 67037080 -> ~64 GB)
|
||||||
|
let gb: number;
|
||||||
|
if (n > MEMORY_THRESHOLDS.VERY_LARGE) {
|
||||||
|
gb = n / 1048576; // KB -> GiB
|
||||||
|
} else if (n > MEMORY_THRESHOLDS.LARGE) {
|
||||||
|
gb = n / 1024; // MB -> GiB
|
||||||
|
} else if (n > MEMORY_THRESHOLDS.MEDIUM) {
|
||||||
|
gb = n; // GB
|
||||||
|
} else {
|
||||||
|
// small numbers treat as GB already
|
||||||
|
gb = n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundedUp = Math.ceil(gb); // round up to the next whole GB
|
||||||
|
return `${roundedUp} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUTCDate(val: string | number | null | undefined): string {
|
||||||
|
if (val === null || val === undefined) return '';
|
||||||
|
|
||||||
|
// try parse ISO or epoch
|
||||||
|
let d: Date;
|
||||||
|
if (typeof val === 'number') {
|
||||||
|
d = new Date(val > 1e12 ? val : val * 1000);
|
||||||
|
} else {
|
||||||
|
const s = String(val).trim();
|
||||||
|
// if numeric string
|
||||||
|
if (/^\d+$/.test(s)) {
|
||||||
|
const num = Number(s);
|
||||||
|
d = new Date(num > 1e12 ? num : num * 1000);
|
||||||
|
} else {
|
||||||
|
d = new Date(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isNaN(d.getTime())) return String(val);
|
||||||
|
|
||||||
|
const yyyy = d.getUTCFullYear();
|
||||||
|
const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getUTCDate()).padStart(2, '0');
|
||||||
|
const HH = String(d.getUTCHours()).padStart(2, '0');
|
||||||
|
const MM = String(d.getUTCMinutes()).padStart(2, '0');
|
||||||
|
const SS = String(d.getUTCSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${yyyy}-${mm}-${dd} ${HH}:${MM}:${SS} UTC`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function memoryToGBNumber(mem: string | number | null | undefined): number {
|
||||||
|
if (mem == null) return 0;
|
||||||
|
let raw = String(mem).trim();
|
||||||
|
const digits = raw.match(/\d+(?:[.,]\d+)?/g);
|
||||||
|
if (digits && digits.length) raw = digits[0].replace(',', '.');
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!isFinite(n) || n <= 0) return 0;
|
||||||
|
if (n > MEMORY_THRESHOLDS.VERY_LARGE) return n / 1048576; // KB -> GiB
|
||||||
|
if (n > MEMORY_THRESHOLDS.LARGE) return n / 1024; // MB -> GiB
|
||||||
|
return n; // assume already GB otherwise
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
export const extractToken = (data: any): string | null => {
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
return parsed.token || parsed.data?.token || null;
|
||||||
|
} catch {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.token) return data.token;
|
||||||
|
if (data.data?.token) return data.data.token;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseHardware = (hardware: any): any => {
|
||||||
|
if (!hardware) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof hardware === 'string') {
|
||||||
|
return JSON.parse(hardware);
|
||||||
|
}
|
||||||
|
return hardware;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const roleCode = (role: any): number => {
|
||||||
|
if (typeof role === 'number') return role;
|
||||||
|
if (typeof role === 'string') {
|
||||||
|
const match = role.match(/-?\d+/);
|
||||||
|
if (match) return Number(match[0]);
|
||||||
|
}
|
||||||
|
return Number(role) || 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const roleLabel = (role: any): string => {
|
||||||
|
const code = roleCode(role);
|
||||||
|
|
||||||
|
switch (code) {
|
||||||
|
case 0: return 'User';
|
||||||
|
case 10: return 'Moderator';
|
||||||
|
case 20: return 'Admin';
|
||||||
|
case 30: return 'Super Admin';
|
||||||
|
case 40: return 'System Admin';
|
||||||
|
case 1000: return 'Root';
|
||||||
|
default: return `Role ${code}`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getLastLoginValue = (lastLogin: any): string => {
|
||||||
|
if (!lastLogin) return 'Never';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(lastLogin);
|
||||||
|
if (isNaN(date.getTime())) return 'Invalid Date';
|
||||||
|
|
||||||
|
return date.toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return 'Invalid Date';
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -166,7 +166,11 @@ function AnnotationMain() {
|
|||||||
const handleKeyDown = (e) => {
|
const handleKeyDown = (e) => {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'Space':
|
case 'Space':
|
||||||
|
// Handle space key if needed
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Handle other keys if needed
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
if (e.ctrlKey && e.key === 'd') {
|
if (e.ctrlKey && e.key === 'd') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useRef, useState, useEffect } from 'react';
|
import React, { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import * as AnnotationService from '../../services/AnnotationService';
|
import * as AnnotationService from '../../services/AnnotationService';
|
||||||
import DetectionContainer from '../DetectionContainer';
|
import DetectionContainer from '../DetectionContainer';
|
||||||
import './CanvasEditor.css';
|
import './CanvasEditor.css';
|
||||||
@@ -21,7 +21,7 @@ function CanvasEditor({
|
|||||||
const [resizeData, setResizeData] = useState(null);
|
const [resizeData, setResizeData] = useState(null);
|
||||||
const [localDetections, setLocalDetections] = useState(detections || []);
|
const [localDetections, setLocalDetections] = useState(detections || []);
|
||||||
const [localSelectedIndices, setLocalSelectedIndices] = useState(selectedDetectionIndices || []);
|
const [localSelectedIndices, setLocalSelectedIndices] = useState(selectedDetectionIndices || []);
|
||||||
const [dimensions, setDimensions] = useState({ width: width || 640, height: height || 480 });
|
const [, setDimensions] = useState({ width: width || 640, height: height || 480 });
|
||||||
|
|
||||||
// Track if we're in a dragging operation
|
// Track if we're in a dragging operation
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
@@ -88,7 +88,7 @@ function CanvasEditor({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseMove = (e) => {
|
const handleMouseMove = useCallback((e) => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef);
|
const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef);
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ function CanvasEditor({
|
|||||||
if (firstSelectedIndex === undefined || !newDetections[firstSelectedIndex]) return;
|
if (firstSelectedIndex === undefined || !newDetections[firstSelectedIndex]) return;
|
||||||
|
|
||||||
const firstSelectedDetection = newDetections[firstSelectedIndex];
|
const firstSelectedDetection = newDetections[firstSelectedIndex];
|
||||||
const { newX1, newY1, newX2, newY2 } = AnnotationService.calculateNewPosition(mouseX, mouseY, dragOffset, firstSelectedDetection, containerRef);
|
const { newX1, newY1 } = AnnotationService.calculateNewPosition(mouseX, mouseY, dragOffset, firstSelectedDetection, containerRef);
|
||||||
const deltaX = newX1 - firstSelectedDetection.x1;
|
const deltaX = newX1 - firstSelectedDetection.x1;
|
||||||
const deltaY = newY1 - firstSelectedDetection.y1;
|
const deltaY = newY1 - firstSelectedDetection.y1;
|
||||||
|
|
||||||
@@ -141,9 +141,9 @@ function CanvasEditor({
|
|||||||
onDetectionsChange(newDetections);
|
onDetectionsChange(newDetections);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [localSelectedIndices, mouseDownPos, resizeData, localDetections, containerRef, onDetectionsChange]);
|
||||||
|
|
||||||
const handleMouseUp = (e) => {
|
const handleMouseUp = useCallback((e) => {
|
||||||
// If we're dragging (or resizing), stop propagation to prevent other elements from reacting
|
// If we're dragging (or resizing), stop propagation to prevent other elements from reacting
|
||||||
if (isDragging || resizeData) {
|
if (isDragging || resizeData) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -178,7 +178,7 @@ function CanvasEditor({
|
|||||||
setDragOffset({ x: 0, y: 0 });
|
setDragOffset({ x: 0, y: 0 });
|
||||||
setResizeData(null);
|
setResizeData(null);
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
};
|
}, [isDragging, resizeData, localSelectedIndices, localDetections, onDetectionsChange, onSelectionChange]);
|
||||||
|
|
||||||
const handleDetectionMouseDown = (e, index) => {
|
const handleDetectionMouseDown = (e, index) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -240,7 +240,7 @@ function CanvasEditor({
|
|||||||
document.removeEventListener('mouseup', handleDocumentMouseUp);
|
document.removeEventListener('mouseup', handleDocumentMouseUp);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [isDragging, resizeData, mouseDownPos]);
|
}, [isDragging, resizeData, mouseDownPos, handleMouseMove, handleMouseUp]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='editor-container' >
|
<div className='editor-container' >
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
import App from './App.tsx';
|
||||||
import reportWebVitals from './reportWebVitals';
|
import reportWebVitals from './reportWebVitals';
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||||
|
|
||||||
|
module.exports = function(app) {
|
||||||
|
// Get API base from environment variable or use default
|
||||||
|
const apiBase = process.env.REACT_APP_API_BASE || 'https://api.azaion.com';
|
||||||
|
|
||||||
|
console.log(`[proxy] /proxy -> ${apiBase}`);
|
||||||
|
|
||||||
|
// Proxy /proxy requests to the Azaion API
|
||||||
|
app.use(
|
||||||
|
'/proxy',
|
||||||
|
createProxyMiddleware({
|
||||||
|
target: apiBase,
|
||||||
|
changeOrigin: true,
|
||||||
|
pathRewrite: {
|
||||||
|
'^/proxy': '', // Remove /proxy prefix when forwarding
|
||||||
|
},
|
||||||
|
onProxyReq: (proxyReq) => {
|
||||||
|
// Ensure JSON content-type is kept if present
|
||||||
|
if (!proxyReq.getHeader('content-type')) {
|
||||||
|
proxyReq.setHeader('content-type', 'application/json');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
logLevel: 'debug', // Enable logging for debugging
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add server info endpoint so UI can auto-detect proxy status
|
||||||
|
app.get('/__server-info', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
proxyEnabled: true,
|
||||||
|
apiBase: apiBase
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await page.goto('https://demo.playwright.dev/todomvc');
|
||||||
|
});
|
||||||
|
|
||||||
|
const TODO_ITEMS = [
|
||||||
|
'buy some cheese',
|
||||||
|
'feed the cat',
|
||||||
|
'book a doctors appointment'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
test.describe('New Todo', () => {
|
||||||
|
test('should allow me to add todo items', async ({ page }) => {
|
||||||
|
// create a new todo locator
|
||||||
|
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||||
|
|
||||||
|
// Create 1st todo.
|
||||||
|
await newTodo.fill(TODO_ITEMS[0]);
|
||||||
|
await newTodo.press('Enter');
|
||||||
|
|
||||||
|
// Make sure the list only has one todo item.
|
||||||
|
await expect(page.getByTestId('todo-title')).toHaveText([
|
||||||
|
TODO_ITEMS[0]
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create 2nd todo.
|
||||||
|
await newTodo.fill(TODO_ITEMS[1]);
|
||||||
|
await newTodo.press('Enter');
|
||||||
|
|
||||||
|
// Make sure the list now has two todo items.
|
||||||
|
await expect(page.getByTestId('todo-title')).toHaveText([
|
||||||
|
TODO_ITEMS[0],
|
||||||
|
TODO_ITEMS[1]
|
||||||
|
]);
|
||||||
|
|
||||||
|
await checkNumberOfTodosInLocalStorage(page, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should clear text input field when an item is added', async ({ page }) => {
|
||||||
|
// create a new todo locator
|
||||||
|
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||||
|
|
||||||
|
// Create one todo item.
|
||||||
|
await newTodo.fill(TODO_ITEMS[0]);
|
||||||
|
await newTodo.press('Enter');
|
||||||
|
|
||||||
|
// Check that input is empty.
|
||||||
|
await expect(newTodo).toBeEmpty();
|
||||||
|
await checkNumberOfTodosInLocalStorage(page, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should append new items to the bottom of the list', async ({ page }) => {
|
||||||
|
// Create 3 items.
|
||||||
|
await createDefaultTodos(page);
|
||||||
|
|
||||||
|
// create a todo count locator
|
||||||
|
const todoCount = page.getByTestId('todo-count')
|
||||||
|
|
||||||
|
// Check test using different methods.
|
||||||
|
await expect(page.getByText('3 items left')).toBeVisible();
|
||||||
|
await expect(todoCount).toHaveText('3 items left');
|
||||||
|
await expect(todoCount).toContainText('3');
|
||||||
|
await expect(todoCount).toHaveText(/3/);
|
||||||
|
|
||||||
|
// Check all items in one call.
|
||||||
|
await expect(page.getByTestId('todo-title')).toHaveText(TODO_ITEMS);
|
||||||
|
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Mark all as completed', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await createDefaultTodos(page);
|
||||||
|
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }) => {
|
||||||
|
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow me to mark all items as completed', async ({ page }) => {
|
||||||
|
// Complete all todos.
|
||||||
|
await page.getByLabel('Mark all as complete').check();
|
||||||
|
|
||||||
|
// Ensure all todos have 'completed' class.
|
||||||
|
await expect(page.getByTestId('todo-item')).toHaveClass(['completed', 'completed', 'completed']);
|
||||||
|
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow me to clear the complete state of all items', async ({ page }) => {
|
||||||
|
const toggleAll = page.getByLabel('Mark all as complete');
|
||||||
|
// Check and then immediately uncheck.
|
||||||
|
await toggleAll.check();
|
||||||
|
await toggleAll.uncheck();
|
||||||
|
|
||||||
|
// Should be no completed classes.
|
||||||
|
await expect(page.getByTestId('todo-item')).toHaveClass(['', '', '']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('complete all checkbox should update state when items are completed / cleared', async ({ page }) => {
|
||||||
|
const toggleAll = page.getByLabel('Mark all as complete');
|
||||||
|
await toggleAll.check();
|
||||||
|
await expect(toggleAll).toBeChecked();
|
||||||
|
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||||
|
|
||||||
|
// Uncheck first todo.
|
||||||
|
const firstTodo = page.getByTestId('todo-item').nth(0);
|
||||||
|
await firstTodo.getByRole('checkbox').uncheck();
|
||||||
|
|
||||||
|
// Reuse toggleAll locator and make sure its not checked.
|
||||||
|
await expect(toggleAll).not.toBeChecked();
|
||||||
|
|
||||||
|
await firstTodo.getByRole('checkbox').check();
|
||||||
|
await checkNumberOfCompletedTodosInLocalStorage(page, 3);
|
||||||
|
|
||||||
|
// Assert the toggle all is checked again.
|
||||||
|
await expect(toggleAll).toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Item', () => {
|
||||||
|
|
||||||
|
test('should allow me to mark items as complete', async ({ page }) => {
|
||||||
|
// create a new todo locator
|
||||||
|
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||||
|
|
||||||
|
// Create two items.
|
||||||
|
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||||
|
await newTodo.fill(item);
|
||||||
|
await newTodo.press('Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check first item.
|
||||||
|
const firstTodo = page.getByTestId('todo-item').nth(0);
|
||||||
|
await firstTodo.getByRole('checkbox').check();
|
||||||
|
await expect(firstTodo).toHaveClass('completed');
|
||||||
|
|
||||||
|
// Check second item.
|
||||||
|
const secondTodo = page.getByTestId('todo-item').nth(1);
|
||||||
|
await expect(secondTodo).not.toHaveClass('completed');
|
||||||
|
await secondTodo.getByRole('checkbox').check();
|
||||||
|
|
||||||
|
// Assert completed class.
|
||||||
|
await expect(firstTodo).toHaveClass('completed');
|
||||||
|
await expect(secondTodo).toHaveClass('completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow me to un-mark items as complete', async ({ page }) => {
|
||||||
|
// create a new todo locator
|
||||||
|
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||||
|
|
||||||
|
// Create two items.
|
||||||
|
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||||
|
await newTodo.fill(item);
|
||||||
|
await newTodo.press('Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstTodo = page.getByTestId('todo-item').nth(0);
|
||||||
|
const secondTodo = page.getByTestId('todo-item').nth(1);
|
||||||
|
const firstTodoCheckbox = firstTodo.getByRole('checkbox');
|
||||||
|
|
||||||
|
await firstTodoCheckbox.check();
|
||||||
|
await expect(firstTodo).toHaveClass('completed');
|
||||||
|
await expect(secondTodo).not.toHaveClass('completed');
|
||||||
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
|
|
||||||
|
await firstTodoCheckbox.uncheck();
|
||||||
|
await expect(firstTodo).not.toHaveClass('completed');
|
||||||
|
await expect(secondTodo).not.toHaveClass('completed');
|
||||||
|
await checkNumberOfCompletedTodosInLocalStorage(page, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow me to edit an item', async ({ page }) => {
|
||||||
|
await createDefaultTodos(page);
|
||||||
|
|
||||||
|
const todoItems = page.getByTestId('todo-item');
|
||||||
|
const secondTodo = todoItems.nth(1);
|
||||||
|
await secondTodo.dblclick();
|
||||||
|
await expect(secondTodo.getByRole('textbox', { name: 'Edit' })).toHaveValue(TODO_ITEMS[1]);
|
||||||
|
await secondTodo.getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
||||||
|
await secondTodo.getByRole('textbox', { name: 'Edit' }).press('Enter');
|
||||||
|
|
||||||
|
// Explicitly assert the new text value.
|
||||||
|
await expect(todoItems).toHaveText([
|
||||||
|
TODO_ITEMS[0],
|
||||||
|
'buy some sausages',
|
||||||
|
TODO_ITEMS[2]
|
||||||
|
]);
|
||||||
|
await checkTodosInLocalStorage(page, 'buy some sausages');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Editing', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await createDefaultTodos(page);
|
||||||
|
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should hide other controls when editing', async ({ page }) => {
|
||||||
|
const todoItem = page.getByTestId('todo-item').nth(1);
|
||||||
|
await todoItem.dblclick();
|
||||||
|
await expect(todoItem.getByRole('checkbox')).not.toBeVisible();
|
||||||
|
await expect(todoItem.locator('label', {
|
||||||
|
hasText: TODO_ITEMS[1],
|
||||||
|
})).not.toBeVisible();
|
||||||
|
await checkNumberOfTodosInLocalStorage(page, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should save edits on blur', async ({ page }) => {
|
||||||
|
const todoItems = page.getByTestId('todo-item');
|
||||||
|
await todoItems.nth(1).dblclick();
|
||||||
|
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
||||||
|
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).dispatchEvent('blur');
|
||||||
|
|
||||||
|
await expect(todoItems).toHaveText([
|
||||||
|
TODO_ITEMS[0],
|
||||||
|
'buy some sausages',
|
||||||
|
TODO_ITEMS[2],
|
||||||
|
]);
|
||||||
|
await checkTodosInLocalStorage(page, 'buy some sausages');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should trim entered text', async ({ page }) => {
|
||||||
|
const todoItems = page.getByTestId('todo-item');
|
||||||
|
await todoItems.nth(1).dblclick();
|
||||||
|
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill(' buy some sausages ');
|
||||||
|
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
|
||||||
|
|
||||||
|
await expect(todoItems).toHaveText([
|
||||||
|
TODO_ITEMS[0],
|
||||||
|
'buy some sausages',
|
||||||
|
TODO_ITEMS[2],
|
||||||
|
]);
|
||||||
|
await checkTodosInLocalStorage(page, 'buy some sausages');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove the item if an empty text string was entered', async ({ page }) => {
|
||||||
|
const todoItems = page.getByTestId('todo-item');
|
||||||
|
await todoItems.nth(1).dblclick();
|
||||||
|
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('');
|
||||||
|
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Enter');
|
||||||
|
|
||||||
|
await expect(todoItems).toHaveText([
|
||||||
|
TODO_ITEMS[0],
|
||||||
|
TODO_ITEMS[2],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should cancel edits on escape', async ({ page }) => {
|
||||||
|
const todoItems = page.getByTestId('todo-item');
|
||||||
|
await todoItems.nth(1).dblclick();
|
||||||
|
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).fill('buy some sausages');
|
||||||
|
await todoItems.nth(1).getByRole('textbox', { name: 'Edit' }).press('Escape');
|
||||||
|
await expect(todoItems).toHaveText(TODO_ITEMS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Counter', () => {
|
||||||
|
test('should display the current number of todo items', async ({ page }) => {
|
||||||
|
// create a new todo locator
|
||||||
|
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||||
|
|
||||||
|
// create a todo count locator
|
||||||
|
const todoCount = page.getByTestId('todo-count')
|
||||||
|
|
||||||
|
await newTodo.fill(TODO_ITEMS[0]);
|
||||||
|
await newTodo.press('Enter');
|
||||||
|
|
||||||
|
await expect(todoCount).toContainText('1');
|
||||||
|
|
||||||
|
await newTodo.fill(TODO_ITEMS[1]);
|
||||||
|
await newTodo.press('Enter');
|
||||||
|
await expect(todoCount).toContainText('2');
|
||||||
|
|
||||||
|
await checkNumberOfTodosInLocalStorage(page, 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Clear completed button', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await createDefaultTodos(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should display the correct text', async ({ page }) => {
|
||||||
|
await page.locator('.todo-list li .toggle').first().check();
|
||||||
|
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should remove completed items when clicked', async ({ page }) => {
|
||||||
|
const todoItems = page.getByTestId('todo-item');
|
||||||
|
await todoItems.nth(1).getByRole('checkbox').check();
|
||||||
|
await page.getByRole('button', { name: 'Clear completed' }).click();
|
||||||
|
await expect(todoItems).toHaveCount(2);
|
||||||
|
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be hidden when there are no items that are completed', async ({ page }) => {
|
||||||
|
await page.locator('.todo-list li .toggle').first().check();
|
||||||
|
await page.getByRole('button', { name: 'Clear completed' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Clear completed' })).toBeHidden();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Persistence', () => {
|
||||||
|
test('should persist its data', async ({ page }) => {
|
||||||
|
// create a new todo locator
|
||||||
|
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||||
|
|
||||||
|
for (const item of TODO_ITEMS.slice(0, 2)) {
|
||||||
|
await newTodo.fill(item);
|
||||||
|
await newTodo.press('Enter');
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoItems = page.getByTestId('todo-item');
|
||||||
|
const firstTodoCheck = todoItems.nth(0).getByRole('checkbox');
|
||||||
|
await firstTodoCheck.check();
|
||||||
|
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
||||||
|
await expect(firstTodoCheck).toBeChecked();
|
||||||
|
await expect(todoItems).toHaveClass(['completed', '']);
|
||||||
|
|
||||||
|
// Ensure there is 1 completed item.
|
||||||
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
|
|
||||||
|
// Now reload.
|
||||||
|
await page.reload();
|
||||||
|
await expect(todoItems).toHaveText([TODO_ITEMS[0], TODO_ITEMS[1]]);
|
||||||
|
await expect(firstTodoCheck).toBeChecked();
|
||||||
|
await expect(todoItems).toHaveClass(['completed', '']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.describe('Routing', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await createDefaultTodos(page);
|
||||||
|
// make sure the app had a chance to save updated todos in storage
|
||||||
|
// before navigating to a new view, otherwise the items can get lost :(
|
||||||
|
// in some frameworks like Durandal
|
||||||
|
await checkTodosInLocalStorage(page, TODO_ITEMS[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow me to display active items', async ({ page }) => {
|
||||||
|
const todoItem = page.getByTestId('todo-item');
|
||||||
|
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
||||||
|
|
||||||
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
|
await page.getByRole('link', { name: 'Active' }).click();
|
||||||
|
await expect(todoItem).toHaveCount(2);
|
||||||
|
await expect(todoItem).toHaveText([TODO_ITEMS[0], TODO_ITEMS[2]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should respect the back button', async ({ page }) => {
|
||||||
|
const todoItem = page.getByTestId('todo-item');
|
||||||
|
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
||||||
|
|
||||||
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
|
|
||||||
|
await test.step('Showing all items', async () => {
|
||||||
|
await page.getByRole('link', { name: 'All' }).click();
|
||||||
|
await expect(todoItem).toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Showing active items', async () => {
|
||||||
|
await page.getByRole('link', { name: 'Active' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step('Showing completed items', async () => {
|
||||||
|
await page.getByRole('link', { name: 'Completed' }).click();
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(todoItem).toHaveCount(1);
|
||||||
|
await page.goBack();
|
||||||
|
await expect(todoItem).toHaveCount(2);
|
||||||
|
await page.goBack();
|
||||||
|
await expect(todoItem).toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow me to display completed items', async ({ page }) => {
|
||||||
|
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
||||||
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
|
await page.getByRole('link', { name: 'Completed' }).click();
|
||||||
|
await expect(page.getByTestId('todo-item')).toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should allow me to display all items', async ({ page }) => {
|
||||||
|
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
|
||||||
|
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
|
||||||
|
await page.getByRole('link', { name: 'Active' }).click();
|
||||||
|
await page.getByRole('link', { name: 'Completed' }).click();
|
||||||
|
await page.getByRole('link', { name: 'All' }).click();
|
||||||
|
await expect(page.getByTestId('todo-item')).toHaveCount(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should highlight the currently applied filter', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
|
||||||
|
|
||||||
|
//create locators for active and completed links
|
||||||
|
const activeLink = page.getByRole('link', { name: 'Active' });
|
||||||
|
const completedLink = page.getByRole('link', { name: 'Completed' });
|
||||||
|
await activeLink.click();
|
||||||
|
|
||||||
|
// Page change - active items.
|
||||||
|
await expect(activeLink).toHaveClass('selected');
|
||||||
|
await completedLink.click();
|
||||||
|
|
||||||
|
// Page change - completed items.
|
||||||
|
await expect(completedLink).toHaveClass('selected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createDefaultTodos(page: Page) {
|
||||||
|
// create a new todo locator
|
||||||
|
const newTodo = page.getByPlaceholder('What needs to be done?');
|
||||||
|
|
||||||
|
for (const item of TODO_ITEMS) {
|
||||||
|
await newTodo.fill(item);
|
||||||
|
await newTodo.press('Enter');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkNumberOfTodosInLocalStorage(page: Page, expected: number) {
|
||||||
|
return await page.waitForFunction(e => {
|
||||||
|
return JSON.parse(localStorage['react-todos']).length === e;
|
||||||
|
}, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkNumberOfCompletedTodosInLocalStorage(page: Page, expected: number) {
|
||||||
|
return await page.waitForFunction(e => {
|
||||||
|
return JSON.parse(localStorage['react-todos']).filter((todo: any) => todo.completed).length === e;
|
||||||
|
}, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkTodosInLocalStorage(page: Page, title: string) {
|
||||||
|
return await page.waitForFunction(t => {
|
||||||
|
return JSON.parse(localStorage['react-todos']).map((todo: any) => todo.title).includes(t);
|
||||||
|
}, title);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('has title', async ({ page }) => {
|
||||||
|
await page.goto('https://playwright.dev/');
|
||||||
|
|
||||||
|
// Expect a title "to contain" a substring.
|
||||||
|
await expect(page).toHaveTitle(/Playwright/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('get started link', async ({ page }) => {
|
||||||
|
await page.goto('https://playwright.dev/');
|
||||||
|
|
||||||
|
// Click the get started link.
|
||||||
|
await page.getByRole('link', { name: 'Get started' }).click();
|
||||||
|
|
||||||
|
// Expects page to have a heading with the name of Installation.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user