add admin part

This commit is contained in:
Oleksandr Bezdieniezhnykh
2025-09-29 14:39:59 +03:00
parent 1e1e8ae97d
commit 9c85bc29ed
32 changed files with 5694 additions and 17529 deletions
+8
View File
@@ -21,3 +21,11 @@
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
+13 -4
View File
@@ -1,19 +1,28 @@
# Azaion Suite # Azaion Suite
Azaion Suite allows to user run detections on videos or photos for military-related objects, like Azaion Suite allows users to run detections on videos or photos for military-related objects, like
military vehicles, tanks, cars, military men, motos, planes, and masked objects. military vehicles, tanks, cars, military men, motos, planes, and masked objects.
Also it allows to do GPS marking by video / photos from GPS camera pointing downwards and start coordinates. Also it allows to do GPS marking by video / photos from GPS camera pointing downwards and start coordinates.
### Install ## Application Structure
The application now combines two parts:
- **Main Annotator App** (accessible at `/`) - The main annotation interface
- **Admin Dashboard** (accessible at `/admin`) - Administrative interface for user management and system controls
## Install
```shell ```shell
npm i -g yarn npm i -g yarn
yarn install yarn install
``` ```
### Debug ## Development
`yarn start` `yarn start`
The application will start on http://localhost:3000:
- Navigate to `/` for the main annotator interface
- Navigate to `/admin` for the admin dashboard (requires admin login)
### Build prod build ## Build Production
`yarn run build` `yarn run build`
+143 -16423
View File
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -10,12 +10,15 @@
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0", "@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"http-proxy-middleware": "^3.0.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-dropzone": "^14.3.8", "react-dropzone": "^14.3.8",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-scripts": "5.0.1", "react-router-dom": "^6.26.2",
"web-vitals": "^2.1.4" "react-scripts": "^5.0.1",
"web-vitals": "^2.1.4",
"yarn": "^1.22.22"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
@@ -40,5 +43,12 @@
"last 1 firefox version", "last 1 firefox version",
"last 1 safari version" "last 1 safari version"
] ]
},
"devDependencies": {
"@playwright/test": "^1.55.1",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"typescript": "^4.9.5"
} }
} }
+79
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+108
View File
@@ -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;
+154
View File
@@ -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;
+165
View File
@@ -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' }}>
&nbsp; {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;
+81
View File
@@ -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;
+138
View File
@@ -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';
+89
View File
@@ -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 => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;'
}[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
}
+66
View File
@@ -0,0 +1,66 @@
export const extractToken = (data: any): string | null => {
if (!data) return null;
if (typeof data === 'string') {
try {
const parsed = JSON.parse(data);
return parsed.token || parsed.data?.token || null;
} catch {
return data;
}
}
if (data.token) return data.token;
if (data.data?.token) return data.data.token;
return null;
};
export const parseHardware = (hardware: any): any => {
if (!hardware) return null;
try {
if (typeof hardware === 'string') {
return JSON.parse(hardware);
}
return hardware;
} catch {
return null;
}
};
export const roleCode = (role: any): number => {
if (typeof role === 'number') return role;
if (typeof role === 'string') {
const match = role.match(/-?\d+/);
if (match) return Number(match[0]);
}
return Number(role) || 0;
};
export const roleLabel = (role: any): string => {
const code = roleCode(role);
switch (code) {
case 0: return 'User';
case 10: return 'Moderator';
case 20: return 'Admin';
case 30: return 'Super Admin';
case 40: return 'System Admin';
case 1000: return 'Root';
default: return `Role ${code}`;
}
};
export const getLastLoginValue = (lastLogin: any): string => {
if (!lastLogin) return 'Never';
try {
const date = new Date(lastLogin);
if (isNaN(date.getTime())) return 'Invalid Date';
return date.toLocaleString();
} catch {
return 'Invalid Date';
}
};
@@ -166,7 +166,11 @@ function AnnotationMain() {
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
switch (e.key) { switch (e.key) {
case 'Space': case 'Space':
// Handle space key if needed
break;
default:
// Handle other keys if needed
break;
} }
if (e.ctrlKey && e.key === 'd') { if (e.ctrlKey && e.key === 'd') {
e.preventDefault(); e.preventDefault();
+8 -8
View File
@@ -1,4 +1,4 @@
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef, useState, useEffect, useCallback } from 'react';
import * as AnnotationService from '../../services/AnnotationService'; import * as AnnotationService from '../../services/AnnotationService';
import DetectionContainer from '../DetectionContainer'; import DetectionContainer from '../DetectionContainer';
import './CanvasEditor.css'; import './CanvasEditor.css';
@@ -21,7 +21,7 @@ function CanvasEditor({
const [resizeData, setResizeData] = useState(null); const [resizeData, setResizeData] = useState(null);
const [localDetections, setLocalDetections] = useState(detections || []); const [localDetections, setLocalDetections] = useState(detections || []);
const [localSelectedIndices, setLocalSelectedIndices] = useState(selectedDetectionIndices || []); const [localSelectedIndices, setLocalSelectedIndices] = useState(selectedDetectionIndices || []);
const [dimensions, setDimensions] = useState({ width: width || 640, height: height || 480 }); const [, setDimensions] = useState({ width: width || 640, height: height || 480 });
// Track if we're in a dragging operation // Track if we're in a dragging operation
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -88,7 +88,7 @@ function CanvasEditor({
} }
}; };
const handleMouseMove = (e) => { const handleMouseMove = useCallback((e) => {
if (!containerRef.current) return; if (!containerRef.current) return;
const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef); const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef);
@@ -101,7 +101,7 @@ function CanvasEditor({
if (firstSelectedIndex === undefined || !newDetections[firstSelectedIndex]) return; if (firstSelectedIndex === undefined || !newDetections[firstSelectedIndex]) return;
const firstSelectedDetection = newDetections[firstSelectedIndex]; const firstSelectedDetection = newDetections[firstSelectedIndex];
const { newX1, newY1, newX2, newY2 } = AnnotationService.calculateNewPosition(mouseX, mouseY, dragOffset, firstSelectedDetection, containerRef); const { newX1, newY1 } = AnnotationService.calculateNewPosition(mouseX, mouseY, dragOffset, firstSelectedDetection, containerRef);
const deltaX = newX1 - firstSelectedDetection.x1; const deltaX = newX1 - firstSelectedDetection.x1;
const deltaY = newY1 - firstSelectedDetection.y1; const deltaY = newY1 - firstSelectedDetection.y1;
@@ -141,9 +141,9 @@ function CanvasEditor({
onDetectionsChange(newDetections); onDetectionsChange(newDetections);
} }
} }
}; }, [localSelectedIndices, mouseDownPos, resizeData, localDetections, containerRef, onDetectionsChange]);
const handleMouseUp = (e) => { const handleMouseUp = useCallback((e) => {
// If we're dragging (or resizing), stop propagation to prevent other elements from reacting // If we're dragging (or resizing), stop propagation to prevent other elements from reacting
if (isDragging || resizeData) { if (isDragging || resizeData) {
e.stopPropagation(); e.stopPropagation();
@@ -178,7 +178,7 @@ function CanvasEditor({
setDragOffset({ x: 0, y: 0 }); setDragOffset({ x: 0, y: 0 });
setResizeData(null); setResizeData(null);
setIsDragging(false); setIsDragging(false);
}; }, [isDragging, resizeData, localSelectedIndices, localDetections, onDetectionsChange, onSelectionChange]);
const handleDetectionMouseDown = (e, index) => { const handleDetectionMouseDown = (e, index) => {
e.stopPropagation(); e.stopPropagation();
@@ -240,7 +240,7 @@ function CanvasEditor({
document.removeEventListener('mouseup', handleDocumentMouseUp); document.removeEventListener('mouseup', handleDocumentMouseUp);
}; };
} }
}, [isDragging, resizeData, mouseDownPos]); }, [isDragging, resizeData, mouseDownPos, handleMouseMove, handleMouseUp]);
return ( return (
<div className='editor-container' > <div className='editor-container' >
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App'; import App from './App.tsx';
import reportWebVitals from './reportWebVitals'; import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
+36
View File
@@ -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
});
});
};
+437
View File
@@ -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);
}
+18
View File
@@ -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();
});
+1561 -1075
View File
File diff suppressed because it is too large Load Diff