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*
yarn-debug.log*
yarn-error.log*
# Playwright
node_modules/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
+14 -5
View File
@@ -1,19 +1,28 @@
# Azaion Suite
Azaion Suite allows to user run detections on videos or photos for military-related objects, like
Azaion Suite allows users to run detections on videos or photos for military-related objects, like
military vehicles, tanks, cars, military men, motos, planes, and masked objects.
Also it allows to do GPS marking by video / photos from GPS camera pointing downwards and start coordinates.
Also it allows to do GPS marking by video / photos from GPS camera pointing downwards and start coordinates.
### Install
## Application Structure
The application now combines two parts:
- **Main Annotator App** (accessible at `/`) - The main annotation interface
- **Admin Dashboard** (accessible at `/admin`) - Administrative interface for user management and system controls
## Install
```shell
npm i -g yarn
yarn install
```
### Debug
## Development
`yarn start`
The application will start on http://localhost:3000:
- Navigate to `/` for the main annotator interface
- Navigate to `/admin` for the admin dashboard (requires admin login)
### Build prod build
## Build Production
`yarn run build`
+144 -16424
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/react": "^16.2.0",
"@testing-library/user-event": "^13.5.0",
"http-proxy-middleware": "^3.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-dropzone": "^14.3.8",
"react-icons": "^5.5.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
"react-router-dom": "^6.26.2",
"react-scripts": "^5.0.1",
"web-vitals": "^2.1.4",
"yarn": "^1.22.22"
},
"scripts": {
"start": "react-scripts start",
@@ -40,5 +43,12 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@playwright/test": "^1.55.1",
"@types/node": "^24.5.2",
"@types/react": "^19.1.13",
"@types/react-dom": "^19.1.9",
"typescript": "^4.9.5"
}
}
+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) => {
switch (e.key) {
case 'Space':
// Handle space key if needed
break;
default:
// Handle other keys if needed
break;
}
if (e.ctrlKey && e.key === 'd') {
e.preventDefault();
+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 DetectionContainer from '../DetectionContainer';
import './CanvasEditor.css';
@@ -21,7 +21,7 @@ function CanvasEditor({
const [resizeData, setResizeData] = useState(null);
const [localDetections, setLocalDetections] = useState(detections || []);
const [localSelectedIndices, setLocalSelectedIndices] = useState(selectedDetectionIndices || []);
const [dimensions, setDimensions] = useState({ width: width || 640, height: height || 480 });
const [, setDimensions] = useState({ width: width || 640, height: height || 480 });
// Track if we're in a dragging operation
const [isDragging, setIsDragging] = useState(false);
@@ -88,7 +88,7 @@ function CanvasEditor({
}
};
const handleMouseMove = (e) => {
const handleMouseMove = useCallback((e) => {
if (!containerRef.current) return;
const { x: mouseX, y: mouseY } = AnnotationService.calculateRelativeCoordinates(e, containerRef);
@@ -101,7 +101,7 @@ function CanvasEditor({
if (firstSelectedIndex === undefined || !newDetections[firstSelectedIndex]) return;
const firstSelectedDetection = newDetections[firstSelectedIndex];
const { newX1, newY1, newX2, newY2 } = AnnotationService.calculateNewPosition(mouseX, mouseY, dragOffset, firstSelectedDetection, containerRef);
const { newX1, newY1 } = AnnotationService.calculateNewPosition(mouseX, mouseY, dragOffset, firstSelectedDetection, containerRef);
const deltaX = newX1 - firstSelectedDetection.x1;
const deltaY = newY1 - firstSelectedDetection.y1;
@@ -141,9 +141,9 @@ function CanvasEditor({
onDetectionsChange(newDetections);
}
}
};
}, [localSelectedIndices, mouseDownPos, resizeData, localDetections, containerRef, onDetectionsChange]);
const handleMouseUp = (e) => {
const handleMouseUp = useCallback((e) => {
// If we're dragging (or resizing), stop propagation to prevent other elements from reacting
if (isDragging || resizeData) {
e.stopPropagation();
@@ -178,7 +178,7 @@ function CanvasEditor({
setDragOffset({ x: 0, y: 0 });
setResizeData(null);
setIsDragging(false);
};
}, [isDragging, resizeData, localSelectedIndices, localDetections, onDetectionsChange, onSelectionChange]);
const handleDetectionMouseDown = (e, index) => {
e.stopPropagation();
@@ -240,7 +240,7 @@ function CanvasEditor({
document.removeEventListener('mouseup', handleDocumentMouseUp);
};
}
}, [isDragging, resizeData, mouseDownPos]);
}, [isDragging, resizeData, mouseDownPos, handleMouseMove, handleMouseUp]);
return (
<div className='editor-container' >
+1 -1
View File
@@ -1,7 +1,7 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import App from './App.tsx';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
+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