mirror of
https://github.com/azaion/ui.git
synced 2026-04-22 11:36:35 +00:00
add admin part
This commit is contained in:
@@ -21,3 +21,11 @@
|
||||
npm-debug.log*
|
||||
yarn-debug.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 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.
|
||||
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
|
||||
npm i -g yarn
|
||||
yarn install
|
||||
```
|
||||
|
||||
### Debug
|
||||
## Development
|
||||
`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`
|
||||
|
||||
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/react": "^16.2.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"http-proxy-middleware": "^3.0.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"web-vitals": "^2.1.4"
|
||||
"react-router-dom": "^6.26.2",
|
||||
"react-scripts": "^5.0.1",
|
||||
"web-vitals": "^2.1.4",
|
||||
"yarn": "^1.22.22"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
@@ -40,5 +43,12 @@
|
||||
"last 1 firefox 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) => {
|
||||
switch (e.key) {
|
||||
case 'Space':
|
||||
|
||||
// Handle space key if needed
|
||||
break;
|
||||
default:
|
||||
// Handle other keys if needed
|
||||
break;
|
||||
}
|
||||
if (e.ctrlKey && e.key === 'd') {
|
||||
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 DetectionContainer from '../DetectionContainer';
|
||||
import './CanvasEditor.css';
|
||||
@@ -21,7 +21,7 @@ function CanvasEditor({
|
||||
const [resizeData, setResizeData] = useState(null);
|
||||
const [localDetections, setLocalDetections] = useState(detections || []);
|
||||
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
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -88,7 +88,7 @@ function CanvasEditor({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
const handleMouseMove = useCallback((e) => {
|
||||
if (!containerRef.current) return;
|
||||
const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef);
|
||||
|
||||
@@ -101,7 +101,7 @@ function CanvasEditor({
|
||||
if (firstSelectedIndex === undefined || !newDetections[firstSelectedIndex]) return;
|
||||
|
||||
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 deltaY = newY1 - firstSelectedDetection.y1;
|
||||
|
||||
@@ -141,9 +141,9 @@ function CanvasEditor({
|
||||
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 (isDragging || resizeData) {
|
||||
e.stopPropagation();
|
||||
@@ -178,7 +178,7 @@ function CanvasEditor({
|
||||
setDragOffset({ x: 0, y: 0 });
|
||||
setResizeData(null);
|
||||
setIsDragging(false);
|
||||
};
|
||||
}, [isDragging, resizeData, localSelectedIndices, localDetections, onDetectionsChange, onSelectionChange]);
|
||||
|
||||
const handleDetectionMouseDown = (e, index) => {
|
||||
e.stopPropagation();
|
||||
@@ -240,7 +240,7 @@ function CanvasEditor({
|
||||
document.removeEventListener('mouseup', handleDocumentMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, resizeData, mouseDownPos]);
|
||||
}, [isDragging, resizeData, mouseDownPos, handleMouseMove, handleMouseUp]);
|
||||
|
||||
return (
|
||||
<div className='editor-container' >
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
import App from './App.tsx';
|
||||
import reportWebVitals from './reportWebVitals';
|
||||
|
||||
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