mirror of
https://github.com/ghndrx/file-transformer-s3.git
synced 2026-02-10 06:45:05 +00:00
Initial commit: File Transformer S3 project with React dashboard and Knative functions
This commit is contained in:
35
dashboard/Dockerfile
Normal file
35
dashboard/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies (use npm install instead of npm ci for better compatibility)
|
||||
RUN npm install --only=production --no-optional
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built application
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:3000 || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
72
dashboard/nginx.conf
Normal file
72
dashboard/nginx.conf
Normal file
@@ -0,0 +1,72 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/json
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
server {
|
||||
listen 3000;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
|
||||
|
||||
# Handle React Router
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Error pages
|
||||
error_page 404 /index.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
location = /50x.html {
|
||||
root /usr/share/nginx/html;
|
||||
}
|
||||
}
|
||||
}
|
||||
62
dashboard/package.json
Normal file
62
dashboard/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "file-transformer-dashboard",
|
||||
"version": "1.0.0",
|
||||
"description": "React dashboard for File Transformer S3",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@testing-library/jest-dom": "^5.16.4",
|
||||
"@testing-library/react": "^13.3.0",
|
||||
"@testing-library/user-event": "^13.5.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-scripts": "5.0.1",
|
||||
"react-router-dom": "^6.3.0",
|
||||
"axios": "^1.4.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-query": "^3.39.3",
|
||||
"react-hot-toast": "^2.4.0",
|
||||
"lucide-react": "^0.263.1",
|
||||
"clsx": "^1.2.1",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.24",
|
||||
"@headlessui/react": "^1.7.15",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"recharts": "^2.7.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"react-hook-form": "^7.45.1",
|
||||
"react-select": "^5.7.3",
|
||||
"react-table": "^7.8.0",
|
||||
"framer-motion": "^10.12.16"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": [
|
||||
"react-app",
|
||||
"react-app/jest"
|
||||
]
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"typescript": "^4.9.5"
|
||||
},
|
||||
"proxy": "http://localhost:8080"
|
||||
}
|
||||
115
dashboard/src/App.css
Normal file
115
dashboard/src/App.css
Normal file
@@ -0,0 +1,115 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Inter', system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-sm border border-gray-200 p-6;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
|
||||
}
|
||||
|
||||
.table-cell {
|
||||
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Loading animation */
|
||||
.loading-spinner {
|
||||
border: 2px solid #f3f3f3;
|
||||
border-top: 2px solid #3498db;
|
||||
border-radius: 50%;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* File upload dropzone */
|
||||
.dropzone {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 8px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.dropzone:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.dropzone.drag-active {
|
||||
border-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
/* Status badges */
|
||||
.status-badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.status-uploaded {
|
||||
@apply bg-blue-100 text-blue-800;
|
||||
}
|
||||
|
||||
.status-processing {
|
||||
@apply bg-yellow-100 text-yellow-800;
|
||||
}
|
||||
|
||||
.status-transformed {
|
||||
@apply bg-green-100 text-green-800;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
@apply bg-red-100 text-red-800;
|
||||
}
|
||||
|
||||
.status-deleted {
|
||||
@apply bg-gray-100 text-gray-800;
|
||||
}
|
||||
67
dashboard/src/App.js
Normal file
67
dashboard/src/App.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import Layout from './components/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Files from './pages/Files';
|
||||
import Upload from './pages/Upload';
|
||||
import Transformations from './pages/Transformations';
|
||||
import Buckets from './pages/Buckets';
|
||||
import Settings from './pages/Settings';
|
||||
import Login from './pages/Login';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
import './App.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<div className="App">
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="files" element={<Files />} />
|
||||
<Route path="upload" element={<Upload />} />
|
||||
<Route path="transformations" element={<Transformations />} />
|
||||
<Route path="buckets" element={<Buckets />} />
|
||||
<Route path="settings" element={<Settings />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
30
dashboard/src/components/Header.js
Normal file
30
dashboard/src/components/Header.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { BellIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const Header = () => {
|
||||
return (
|
||||
<header className="bg-white shadow-sm border-b border-gray-200">
|
||||
<div className="flex items-center justify-between h-16 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center">
|
||||
<h2 className="text-lg font-medium text-gray-900">File Transformer S3</h2>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<BellIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="h-8 w-8 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">A</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-gray-700">Admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
20
dashboard/src/components/Layout.js
Normal file
20
dashboard/src/components/Layout.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import Sidebar from './Sidebar';
|
||||
import Header from './Header';
|
||||
|
||||
const Layout = () => {
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-50 p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
15
dashboard/src/components/ProtectedRoute.js
Normal file
15
dashboard/src/components/ProtectedRoute.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
// For now, always allow access. In a real app, you'd check authentication here
|
||||
const isAuthenticated = true; // Replace with actual auth check
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
59
dashboard/src/components/Sidebar.js
Normal file
59
dashboard/src/components/Sidebar.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import {
|
||||
HomeIcon,
|
||||
DocumentTextIcon,
|
||||
CloudArrowUpIcon,
|
||||
CogIcon,
|
||||
FolderIcon,
|
||||
ChartBarIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const Sidebar = () => {
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: HomeIcon },
|
||||
{ name: 'Files', href: '/files', icon: DocumentTextIcon },
|
||||
{ name: 'Upload', href: '/upload', icon: CloudArrowUpIcon },
|
||||
{ name: 'Transformations', href: '/transformations', icon: CogIcon },
|
||||
{ name: 'Buckets', href: '/buckets', icon: FolderIcon },
|
||||
{ name: 'Analytics', href: '/analytics', icon: ChartBarIcon },
|
||||
{ name: 'Settings', href: '/settings', icon: CogIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="flex flex-col h-0 flex-1 bg-white border-r border-gray-200">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<div className="flex items-center flex-shrink-0 px-4">
|
||||
<h1 className="text-xl font-semibold text-gray-900">File Transformer</h1>
|
||||
</div>
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
{navigation.map((item) => (
|
||||
<NavLink
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={({ isActive }) =>
|
||||
`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
||||
isActive
|
||||
? 'bg-blue-100 text-blue-900'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon
|
||||
className="mr-3 flex-shrink-0 h-6 w-6"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.name}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
66
dashboard/src/contexts/AuthContext.js
Normal file
66
dashboard/src/contexts/AuthContext.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing token
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
// In a real app, validate the token with the backend
|
||||
setUser({ id: '1', username: 'admin', role: 'admin' });
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
const login = async (credentials) => {
|
||||
try {
|
||||
// In a real app, make API call to login
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
localStorage.setItem('authToken', data.token);
|
||||
setUser(data.user);
|
||||
return { success: true };
|
||||
} else {
|
||||
return { success: false, error: 'Invalid credentials' };
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: 'Network error' };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
const value = {
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
loading,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
20
dashboard/src/index.css
Normal file
20
dashboard/src/index.css
Normal file
@@ -0,0 +1,20 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
11
dashboard/src/index.js
Normal file
11
dashboard/src/index.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
94
dashboard/src/pages/Buckets.js
Normal file
94
dashboard/src/pages/Buckets.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { FolderIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import { bucketsAPI } from '../services/api';
|
||||
|
||||
const Buckets = () => {
|
||||
const { data: buckets, isLoading, error } = useQuery('buckets', bucketsAPI.getBuckets);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">Error loading buckets: {error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Buckets</h1>
|
||||
<p className="text-gray-600">Manage MinIO storage buckets</p>
|
||||
</div>
|
||||
<button className="btn-primary flex items-center">
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Create Bucket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{buckets?.map((bucket) => (
|
||||
<div key={bucket.name} className="card hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<FolderIcon className="w-8 h-8 text-blue-500 mr-3" />
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">{bucket.name}</h3>
|
||||
<p className="text-sm text-gray-500">{bucket.description || 'No description'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
bucket.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{bucket.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">Created</span>
|
||||
<span className="text-gray-900">
|
||||
{new Date(bucket.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex space-x-2">
|
||||
<button className="btn-secondary text-sm">View Files</button>
|
||||
<button className="btn-secondary text-sm">Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{buckets?.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<FolderIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No buckets</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Get started by creating a new bucket.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<button className="btn-primary">
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Create Bucket
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Buckets;
|
||||
223
dashboard/src/pages/Dashboard.js
Normal file
223
dashboard/src/pages/Dashboard.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import {
|
||||
CloudArrowUpIcon,
|
||||
DocumentTextIcon,
|
||||
CogIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
|
||||
import { format } from 'date-fns';
|
||||
import { motion } from 'framer-motion';
|
||||
import { getDashboardStats, getRecentFiles, getRecentTransformations } from '../services/api';
|
||||
|
||||
const COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444'];
|
||||
|
||||
const Dashboard = () => {
|
||||
const { data: stats, isLoading: statsLoading } = useQuery('dashboardStats', getDashboardStats);
|
||||
const { data: recentFiles, isLoading: filesLoading } = useQuery('recentFiles', getRecentFiles);
|
||||
const { data: recentTransformations, isLoading: transformationsLoading } = useQuery('recentTransformations', getRecentTransformations);
|
||||
|
||||
const fileTypeData = [
|
||||
{ name: 'PDF', value: stats?.fileTypes?.pdf || 0 },
|
||||
{ name: 'DOC', value: stats?.fileTypes?.doc || 0 },
|
||||
{ name: 'TXT', value: stats?.fileTypes?.txt || 0 },
|
||||
{ name: 'CSV', value: stats?.fileTypes?.csv || 0 },
|
||||
];
|
||||
|
||||
const statusData = [
|
||||
{ name: 'Uploaded', value: stats?.statusCounts?.uploaded || 0 },
|
||||
{ name: 'Processing', value: stats?.statusCounts?.processing || 0 },
|
||||
{ name: 'Transformed', value: stats?.statusCounts?.transformed || 0 },
|
||||
{ name: 'Error', value: stats?.statusCounts?.error || 0 },
|
||||
];
|
||||
|
||||
const StatCard = ({ title, value, icon: Icon, change, changeType = 'up' }) => (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="card"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-600">{title}</p>
|
||||
<p className="text-2xl font-bold text-gray-900">{value}</p>
|
||||
{change && (
|
||||
<div className="flex items-center mt-2">
|
||||
{changeType === 'up' ? (
|
||||
<ArrowUpIcon className="w-4 h-4 text-green-500" />
|
||||
) : (
|
||||
<ArrowDownIcon className="w-4 h-4 text-red-500" />
|
||||
)}
|
||||
<span className={`text-sm font-medium ${
|
||||
changeType === 'up' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{change}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 rounded-lg">
|
||||
<Icon className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
if (statsLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600">Overview of your file transformation system</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<StatCard
|
||||
title="Total Files"
|
||||
value={stats?.totalFiles || 0}
|
||||
icon={DocumentTextIcon}
|
||||
change="+12%"
|
||||
changeType="up"
|
||||
/>
|
||||
<StatCard
|
||||
title="Storage Used"
|
||||
value={`${(stats?.totalSize / (1024 * 1024 * 1024)).toFixed(1)} GB`}
|
||||
icon={CloudArrowUpIcon}
|
||||
change="+8%"
|
||||
changeType="up"
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Transformations"
|
||||
value={stats?.activeTransformations || 0}
|
||||
icon={CogIcon}
|
||||
change="-3%"
|
||||
changeType="down"
|
||||
/>
|
||||
<StatCard
|
||||
title="Failed Jobs"
|
||||
value={stats?.failedJobs || 0}
|
||||
icon={ExclamationTriangleIcon}
|
||||
change="+2%"
|
||||
changeType="up"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* File Types Chart */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">File Types Distribution</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={fileTypeData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill="#8884d8"
|
||||
dataKey="value"
|
||||
>
|
||||
{fileTypeData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
{/* Status Chart */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">File Status Overview</h3>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={statusData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="name" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="value" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Files */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Files</h3>
|
||||
{filesLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentFiles?.slice(0, 5).map((file) => (
|
||||
<div key={file.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<DocumentTextIcon className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{file.filename}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{format(new Date(file.created_at), 'MMM dd, yyyy HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`status-badge status-${file.status}`}>
|
||||
{file.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent Transformations */}
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Transformations</h3>
|
||||
{transformationsLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentTransformations?.slice(0, 5).map((transformation) => (
|
||||
<div key={transformation.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-3">
|
||||
<CogIcon className="w-5 h-5 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">{transformation.transformation_type}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{format(new Date(transformation.created_at), 'MMM dd, yyyy HH:mm')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`status-badge status-${transformation.status}`}>
|
||||
{transformation.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
102
dashboard/src/pages/Files.js
Normal file
102
dashboard/src/pages/Files.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { DocumentTextIcon, ArrowDownTrayIcon, TrashIcon } from '@heroicons/react/24/outline';
|
||||
import { filesAPI } from '../services/api';
|
||||
|
||||
const Files = () => {
|
||||
const { data: files, isLoading, error } = useQuery('files', filesAPI.getFiles);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">Error loading files: {error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Files</h1>
|
||||
<p className="text-gray-600">Manage your uploaded files</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">All Files</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="table-header">File</th>
|
||||
<th className="table-header">Size</th>
|
||||
<th className="table-header">Type</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">Uploaded</th>
|
||||
<th className="table-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{files?.map((file) => (
|
||||
<tr key={file.id} className="hover:bg-gray-50">
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center">
|
||||
<DocumentTextIcon className="w-5 h-5 text-gray-400 mr-3" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{file.filename}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{file.original_filename}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className="text-sm text-gray-900">
|
||||
{(file.file_size / 1024 / 1024).toFixed(2)} MB
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className="text-sm text-gray-900">{file.file_type}</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className={`status-badge status-${file.status}`}>
|
||||
{file.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className="text-sm text-gray-900">
|
||||
{new Date(file.created_at).toLocaleDateString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex space-x-2">
|
||||
<button className="text-blue-600 hover:text-blue-900">
|
||||
<ArrowDownTrayIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="text-red-600 hover:text-red-900">
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Files;
|
||||
111
dashboard/src/pages/Login.js
Normal file
111
dashboard/src/pages/Login.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Login = () => {
|
||||
const [credentials, setCredentials] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await login(credentials);
|
||||
if (result.success) {
|
||||
toast.success('Login successful!');
|
||||
navigate('/');
|
||||
} else {
|
||||
toast.error(result.error || 'Login failed');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('An error occurred during login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setCredentials({
|
||||
...credentials,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Sign in to File Transformer
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Access your file transformation dashboard
|
||||
</p>
|
||||
</div>
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Username"
|
||||
value={credentials.username}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={credentials.password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="loading-spinner"></div>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Default credentials: admin / admin123
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
205
dashboard/src/pages/Settings.js
Normal file
205
dashboard/src/pages/Settings.js
Normal file
@@ -0,0 +1,205 @@
|
||||
import React from 'react';
|
||||
import { CogIcon, ShieldCheckIcon, CircleStackIcon, CloudIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const Settings = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||
<p className="text-gray-600">Configure your file transformation system</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* System Configuration */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<CogIcon className="w-6 h-6 text-blue-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">System Configuration</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Application Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
defaultValue="File Transformer S3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Environment
|
||||
</label>
|
||||
<select className="input-field">
|
||||
<option value="development">Development</option>
|
||||
<option value="staging">Staging</option>
|
||||
<option value="production">Production</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Debug Mode
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
defaultChecked
|
||||
/>
|
||||
<label className="ml-2 text-sm text-gray-700">Enable debug logging</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Settings */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<ShieldCheckIcon className="w-6 h-6 text-green-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Security</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Session Timeout (hours)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
defaultValue="24"
|
||||
min="1"
|
||||
max="168"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Max File Size (MB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
defaultValue="100"
|
||||
min="1"
|
||||
max="1000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Allowed File Types
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
defaultValue="pdf,doc,docx,txt,csv,xlsx,xls,json,xml"
|
||||
placeholder="Comma-separated file extensions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Database Configuration */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<CircleStackIcon className="w-6 h-6 text-purple-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Database</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
defaultValue="localhost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="input-field"
|
||||
defaultValue="5432"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
defaultValue="file_transformer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Storage Configuration */}
|
||||
<div className="card">
|
||||
<div className="flex items-center mb-4">
|
||||
<CloudIcon className="w-6 h-6 text-orange-500 mr-2" />
|
||||
<h3 className="text-lg font-medium text-gray-900">Storage</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
MinIO Endpoint
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
defaultValue="localhost:9000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Default Bucket
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input-field"
|
||||
defaultValue="file-transformer-bucket"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Use SSL
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 text-sm text-gray-700">Enable SSL for MinIO</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<button className="btn-secondary">Reset to Defaults</button>
|
||||
<button className="btn-primary">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
||||
120
dashboard/src/pages/Transformations.js
Normal file
120
dashboard/src/pages/Transformations.js
Normal file
@@ -0,0 +1,120 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { CogIcon, CheckCircleIcon, XCircleIcon, ClockIcon } from '@heroicons/react/24/outline';
|
||||
import { transformationsAPI } from '../services/api';
|
||||
|
||||
const Transformations = () => {
|
||||
const { data: transformations, isLoading, error } = useQuery('transformations', transformationsAPI.getTransformations);
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircleIcon className="w-5 h-5 text-red-500" />;
|
||||
case 'processing':
|
||||
return <CogIcon className="w-5 h-5 text-blue-500 animate-spin" />;
|
||||
default:
|
||||
return <ClockIcon className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">Error loading transformations: {error.message}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Transformations</h1>
|
||||
<p className="text-gray-600">Monitor file transformation jobs</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">All Transformations</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="table-header">Type</th>
|
||||
<th className="table-header">File</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">Started</th>
|
||||
<th className="table-header">Completed</th>
|
||||
<th className="table-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{transformations?.map((transformation) => (
|
||||
<tr key={transformation.id} className="hover:bg-gray-50">
|
||||
<td className="table-cell">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{transformation.transformation_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className="text-sm text-gray-900">
|
||||
{transformation.file_id}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex items-center">
|
||||
{getStatusIcon(transformation.status)}
|
||||
<span className={`ml-2 status-badge status-${transformation.status}`}>
|
||||
{transformation.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className="text-sm text-gray-900">
|
||||
{transformation.started_at
|
||||
? new Date(transformation.started_at).toLocaleString()
|
||||
: '-'
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<span className="text-sm text-gray-900">
|
||||
{transformation.completed_at
|
||||
? new Date(transformation.completed_at).toLocaleString()
|
||||
: '-'
|
||||
}
|
||||
</span>
|
||||
</td>
|
||||
<td className="table-cell">
|
||||
<div className="flex space-x-2">
|
||||
{transformation.status === 'failed' && (
|
||||
<button className="text-blue-600 hover:text-blue-900 text-sm">
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
<button className="text-gray-600 hover:text-gray-900 text-sm">
|
||||
View Logs
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Transformations;
|
||||
159
dashboard/src/pages/Upload.js
Normal file
159
dashboard/src/pages/Upload.js
Normal file
@@ -0,0 +1,159 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { CloudArrowUpIcon, DocumentTextIcon } from '@heroicons/react/24/outline';
|
||||
import { uploadFileWithProgress } from '../services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Upload = () => {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState({});
|
||||
|
||||
const onDrop = useCallback(async (acceptedFiles) => {
|
||||
setUploading(true);
|
||||
|
||||
for (const file of acceptedFiles) {
|
||||
try {
|
||||
setUploadProgress(prev => ({ ...prev, [file.name]: 0 }));
|
||||
|
||||
await uploadFileWithProgress(file, (progress) => {
|
||||
setUploadProgress(prev => ({ ...prev, [file.name]: progress }));
|
||||
});
|
||||
|
||||
toast.success(`${file.name} uploaded successfully!`);
|
||||
setUploadProgress(prev => {
|
||||
const newProgress = { ...prev };
|
||||
delete newProgress[file.name];
|
||||
return newProgress;
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(`Failed to upload ${file.name}: ${error.message}`);
|
||||
setUploadProgress(prev => {
|
||||
const newProgress = { ...prev };
|
||||
delete newProgress[file.name];
|
||||
return newProgress;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false);
|
||||
}, []);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'application/pdf': ['.pdf'],
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
'application/msword': ['.doc'],
|
||||
'text/plain': ['.txt'],
|
||||
'text/csv': ['.csv'],
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'],
|
||||
'application/vnd.ms-excel': ['.xls'],
|
||||
'application/json': ['.json'],
|
||||
'application/xml': ['.xml'],
|
||||
'image/*': ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff']
|
||||
},
|
||||
maxSize: 100 * 1024 * 1024, // 100MB
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Upload Files</h1>
|
||||
<p className="text-gray-600">Upload files for transformation and processing</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`dropzone ${isDragActive ? 'drag-active' : ''} ${
|
||||
uploading ? 'opacity-50 pointer-events-none' : ''
|
||||
}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<CloudArrowUpIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="mt-4 text-center">
|
||||
{isDragActive ? (
|
||||
<p className="text-lg text-blue-600">Drop the files here...</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg text-gray-900">
|
||||
Drag and drop files here, or click to select files
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
Supports PDF, DOC, TXT, CSV, Excel, JSON, XML, and image files (max 100MB)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Progress */}
|
||||
{Object.keys(uploadProgress).length > 0 && (
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Upload Progress</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(uploadProgress).map(([filename, progress]) => (
|
||||
<div key={filename} className="flex items-center space-x-3">
|
||||
<DocumentTextIcon className="w-5 h-5 text-gray-400" />
|
||||
<div className="flex-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-900">{filename}</span>
|
||||
<span className="text-gray-500">{progress}%</span>
|
||||
</div>
|
||||
<div className="mt-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Type Information */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Supported File Types</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Documents</span>
|
||||
<span className="text-gray-900">PDF, DOC, DOCX, TXT</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Data Files</span>
|
||||
<span className="text-gray-900">CSV, XLS, XLSX, JSON, XML</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Images</span>
|
||||
<span className="text-gray-900">JPG, PNG, GIF, BMP, TIFF</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">Transformation Options</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Text Extraction</span>
|
||||
<span className="text-green-600">Available</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Format Conversion</span>
|
||||
<span className="text-green-600">Available</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Image Processing</span>
|
||||
<span className="text-green-600">Available</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Upload;
|
||||
180
dashboard/src/services/api.js
Normal file
180
dashboard/src/services/api.js
Normal file
@@ -0,0 +1,180 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080';
|
||||
|
||||
// Create axios instance with default config
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor to handle auth errors
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
login: (credentials) => api.post('/auth/login', credentials),
|
||||
logout: () => api.post('/auth/logout'),
|
||||
register: (userData) => api.post('/auth/register', userData),
|
||||
getProfile: () => api.get('/auth/profile'),
|
||||
};
|
||||
|
||||
// Files API
|
||||
export const filesAPI = {
|
||||
getFiles: (params = {}) => api.get('/files', { params }),
|
||||
getFile: (id) => api.get(`/files/${id}`),
|
||||
uploadFile: (file, onProgress) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return api.post('/files/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
onUploadProgress: onProgress,
|
||||
});
|
||||
},
|
||||
deleteFile: (id) => api.delete(`/files/${id}`),
|
||||
downloadFile: (id) => api.get(`/files/${id}/download`, { responseType: 'blob' }),
|
||||
updateFile: (id, data) => api.put(`/files/${id}`, data),
|
||||
getFileMetadata: (id) => api.get(`/files/${id}/metadata`),
|
||||
};
|
||||
|
||||
// Transformations API
|
||||
export const transformationsAPI = {
|
||||
getTransformations: (params = {}) => api.get('/transformations', { params }),
|
||||
getTransformation: (id) => api.get(`/transformations/${id}`),
|
||||
createTransformation: (data) => api.post('/transformations', data),
|
||||
updateTransformation: (id, data) => api.put(`/transformations/${id}`, data),
|
||||
deleteTransformation: (id) => api.delete(`/transformations/${id}`),
|
||||
retryTransformation: (id) => api.post(`/transformations/${id}/retry`),
|
||||
getTransformationLogs: (id) => api.get(`/transformations/${id}/logs`),
|
||||
};
|
||||
|
||||
// Buckets API
|
||||
export const bucketsAPI = {
|
||||
getBuckets: () => api.get('/buckets'),
|
||||
getBucket: (name) => api.get(`/buckets/${name}`),
|
||||
createBucket: (data) => api.post('/buckets', data),
|
||||
deleteBucket: (name) => api.delete(`/buckets/${name}`),
|
||||
getBucketStats: (name) => api.get(`/buckets/${name}/stats`),
|
||||
getBucketFiles: (name, params = {}) => api.get(`/buckets/${name}/files`, { params }),
|
||||
};
|
||||
|
||||
// Dashboard API
|
||||
export const dashboardAPI = {
|
||||
getStats: () => api.get('/dashboard/stats'),
|
||||
getRecentFiles: (limit = 10) => api.get('/dashboard/recent-files', { params: { limit } }),
|
||||
getRecentTransformations: (limit = 10) => api.get('/dashboard/recent-transformations', { params: { limit } }),
|
||||
getFileTypeStats: () => api.get('/dashboard/file-types'),
|
||||
getStatusStats: () => api.get('/dashboard/status-counts'),
|
||||
getStorageStats: () => api.get('/dashboard/storage'),
|
||||
};
|
||||
|
||||
// MinIO API (direct integration)
|
||||
export const minioAPI = {
|
||||
getBuckets: () => api.get('/minio/buckets'),
|
||||
getObjects: (bucketName, prefix = '') => api.get(`/minio/buckets/${bucketName}/objects`, { params: { prefix } }),
|
||||
uploadObject: (bucketName, objectKey, file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('objectKey', objectKey);
|
||||
|
||||
return api.post(`/minio/buckets/${bucketName}/upload`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
},
|
||||
deleteObject: (bucketName, objectKey) => api.delete(`/minio/buckets/${bucketName}/objects/${objectKey}`),
|
||||
getObjectUrl: (bucketName, objectKey) => api.get(`/minio/buckets/${bucketName}/objects/${objectKey}/url`),
|
||||
};
|
||||
|
||||
// Convenience functions for common operations
|
||||
export const getDashboardStats = async () => {
|
||||
const [stats, fileTypes, statusCounts, storage] = await Promise.all([
|
||||
dashboardAPI.getStats(),
|
||||
dashboardAPI.getFileTypeStats(),
|
||||
dashboardAPI.getStatusStats(),
|
||||
dashboardAPI.getStorageStats(),
|
||||
]);
|
||||
|
||||
return {
|
||||
...stats.data,
|
||||
fileTypes: fileTypes.data,
|
||||
statusCounts: statusCounts.data,
|
||||
storage: storage.data,
|
||||
};
|
||||
};
|
||||
|
||||
export const getRecentFiles = async () => {
|
||||
const response = await dashboardAPI.getRecentFiles();
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const getRecentTransformations = async () => {
|
||||
const response = await dashboardAPI.getRecentTransformations();
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const uploadFileWithProgress = (file, onProgress) => {
|
||||
return filesAPI.uploadFile(file, (progressEvent) => {
|
||||
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||
onProgress(percentCompleted);
|
||||
});
|
||||
};
|
||||
|
||||
export const downloadFileAsBlob = async (fileId, filename) => {
|
||||
const response = await filesAPI.downloadFile(fileId);
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// Error handling utility
|
||||
export const handleAPIError = (error) => {
|
||||
if (error.response) {
|
||||
// Server responded with error status
|
||||
const message = error.response.data?.message || error.response.data?.error || 'An error occurred';
|
||||
return { error: true, message, status: error.response.status };
|
||||
} else if (error.request) {
|
||||
// Request was made but no response received
|
||||
return { error: true, message: 'Network error. Please check your connection.', status: 0 };
|
||||
} else {
|
||||
// Something else happened
|
||||
return { error: true, message: error.message || 'An unexpected error occurred.', status: 0 };
|
||||
}
|
||||
};
|
||||
|
||||
export default api;
|
||||
43
dashboard/tailwind.config.js
Normal file
43
dashboard/tailwind.config.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
Reference in New Issue
Block a user