mongo-scheduler-ui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +1 -0
- package/README.md +122 -0
- package/eslint.config.js +23 -0
- package/index.html +13 -0
- package/package.json +44 -0
- package/postcss.config.js +6 -0
- package/public/dashboard-preview.png +0 -0
- package/public/favicon.svg +12 -0
- package/src/App.css +42 -0
- package/src/App.tsx +233 -0
- package/src/components/JobDetails.tsx +124 -0
- package/src/components/JobFilters.tsx +59 -0
- package/src/components/JobFormModal.tsx +372 -0
- package/src/components/JobList.tsx +171 -0
- package/src/components/Pagination.tsx +107 -0
- package/src/components/StatsCards.tsx +52 -0
- package/src/index.css +29 -0
- package/src/main.tsx +10 -0
- package/src/services/api.ts +58 -0
- package/src/types/job.ts +43 -0
- package/src/utils/helpers.ts +31 -0
- package/tailwind.config.js +8 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +7 -0
package/.env.example
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VITE_API_URL=http://localhost:3000
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Mongo Scheduler UI
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
Beautiful, responsive dashboard for managing jobs from `mongo-job-scheduler`.
|
|
6
|
+
|
|
7
|
+
## 🚀 Quick Start
|
|
8
|
+
|
|
9
|
+
### Prerequisites
|
|
10
|
+
|
|
11
|
+
- Node.js 18+
|
|
12
|
+
- MongoDB running locally or remote connection string
|
|
13
|
+
- mongo-job-scheduler jobs in your database
|
|
14
|
+
|
|
15
|
+
### 1. Frontend Setup
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
cd mongo-scheduler-ui
|
|
19
|
+
npm install
|
|
20
|
+
npm run dev
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The UI will be available at `http://localhost:5173`
|
|
24
|
+
|
|
25
|
+
### 2. Backend API Setup
|
|
26
|
+
|
|
27
|
+
Clone and setup the **[Mongo Scheduler API](https://github.com/darshanpatel14/mongo-job-scheduler-api)** repository. Follow the instructions in its README to start the server at `http://localhost:3000`.
|
|
28
|
+
|
|
29
|
+
The API will be available at `http://localhost:3000`
|
|
30
|
+
|
|
31
|
+
## 📁 Project Structure
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
mongo-scheduler-ui/
|
|
35
|
+
├── src/ # React frontend
|
|
36
|
+
│ ├── components/ # UI components
|
|
37
|
+
│ ├── services/ # API client
|
|
38
|
+
│ ├── types/ # TypeScript types
|
|
39
|
+
│ └── utils/ # Helper functions
|
|
40
|
+
└── package.json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## 🎨 Features
|
|
44
|
+
|
|
45
|
+
- **📊 Real-time Dashboard** - Auto-refresh every 5 seconds
|
|
46
|
+
- **🔍 Search & Filter** - Find jobs by name and status
|
|
47
|
+
- **📈 Statistics** - Total, pending, running, completed, failed jobs
|
|
48
|
+
- **🎯 Job Actions** - Delete, retry (failed), cancel (pending/running)
|
|
49
|
+
- **📝 Job Details** - Full job information in a beautiful modal
|
|
50
|
+
- **🎨 Modern Dark Theme** - Gradient-based design with glassmorphism
|
|
51
|
+
- **📱 Responsive** - Works on mobile, tablet, and desktop
|
|
52
|
+
|
|
53
|
+
## 🔌 API Endpoints
|
|
54
|
+
|
|
55
|
+
See `server/README.md` for full API documentation.
|
|
56
|
+
|
|
57
|
+
## 🎯 Usage
|
|
58
|
+
|
|
59
|
+
1. Start MongoDB
|
|
60
|
+
2. Start the backend API: `cd server && npm run dev`
|
|
61
|
+
3. Start the frontend: `npm run dev`
|
|
62
|
+
4. Open `http://localhost:5173` in your browser
|
|
63
|
+
|
|
64
|
+
## 🔧 Configuration
|
|
65
|
+
|
|
66
|
+
### Frontend (.env)
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
VITE_API_URL=http://localhost:3000
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Backend (server/.env)
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
MONGO_URL=mongodb://localhost:27017
|
|
76
|
+
DB_NAME=scheduler
|
|
77
|
+
PORT=3000
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 🎨 Customization
|
|
81
|
+
|
|
82
|
+
### Add Job Handlers
|
|
83
|
+
|
|
84
|
+
Edit `server/index.js`:
|
|
85
|
+
|
|
86
|
+
```javascript
|
|
87
|
+
handler: async (job) => {
|
|
88
|
+
if (job.name === "send-email") {
|
|
89
|
+
// Your email logic
|
|
90
|
+
} else if (job.name === "process-payment") {
|
|
91
|
+
// Your payment logic
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Modify Theme
|
|
97
|
+
|
|
98
|
+
Edit `src/index.css` for colors and styling.
|
|
99
|
+
|
|
100
|
+
## 📦 Tech Stack
|
|
101
|
+
|
|
102
|
+
- **Frontend**: React 18, Vite, TypeScript, Tailwind CSS
|
|
103
|
+
- **Backend**: Express, mongo-job-scheduler, MongoDB
|
|
104
|
+
- **Icons**: Lucide React
|
|
105
|
+
- **Date Formatting**: date-fns
|
|
106
|
+
|
|
107
|
+
## 🚢 Deployment
|
|
108
|
+
|
|
109
|
+
### Frontend
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npm run build
|
|
113
|
+
# Deploy dist/ folder to Vercel, Netlify, etc.
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Backend
|
|
117
|
+
|
|
118
|
+
Please refer to the **[Mongo Scheduler API](https://github.com/darshanpatel14/mongo-job-scheduler-api)** repository for deployment instructions.
|
|
119
|
+
|
|
120
|
+
## 📄 License
|
|
121
|
+
|
|
122
|
+
MIT
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import js from '@eslint/js'
|
|
2
|
+
import globals from 'globals'
|
|
3
|
+
import reactHooks from 'eslint-plugin-react-hooks'
|
|
4
|
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
5
|
+
import tseslint from 'typescript-eslint'
|
|
6
|
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
7
|
+
|
|
8
|
+
export default defineConfig([
|
|
9
|
+
globalIgnores(['dist']),
|
|
10
|
+
{
|
|
11
|
+
files: ['**/*.{ts,tsx}'],
|
|
12
|
+
extends: [
|
|
13
|
+
js.configs.recommended,
|
|
14
|
+
tseslint.configs.recommended,
|
|
15
|
+
reactHooks.configs.flat.recommended,
|
|
16
|
+
reactRefresh.configs.vite,
|
|
17
|
+
],
|
|
18
|
+
languageOptions: {
|
|
19
|
+
ecmaVersion: 2020,
|
|
20
|
+
globals: globals.browser,
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
])
|
package/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Mongo Scheduler UI</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "mongo-scheduler-ui",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Modern React UI Dashboard for mongo-job-scheduler",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/darshanpatel14/mongo-job-scheduler-ui.git"
|
|
8
|
+
},
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/darshanpatel14/mongo-job-scheduler-ui/issues"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/darshanpatel14/mongo-job-scheduler-ui#readme",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"dev": "vite",
|
|
16
|
+
"build": "tsc -b && vite build",
|
|
17
|
+
"lint": "eslint .",
|
|
18
|
+
"preview": "vite preview"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"axios": "^1.13.2",
|
|
22
|
+
"date-fns": "^4.1.0",
|
|
23
|
+
"lucide-react": "^0.562.0",
|
|
24
|
+
"react": "^19.2.0",
|
|
25
|
+
"react-dom": "^19.2.0"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@eslint/js": "^9.39.1",
|
|
29
|
+
"@types/node": "^24.10.4",
|
|
30
|
+
"@types/react": "^19.2.5",
|
|
31
|
+
"@types/react-dom": "^19.2.3",
|
|
32
|
+
"@vitejs/plugin-react": "^5.1.1",
|
|
33
|
+
"autoprefixer": "^10.4.23",
|
|
34
|
+
"eslint": "^9.39.1",
|
|
35
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
36
|
+
"eslint-plugin-react-refresh": "^0.4.24",
|
|
37
|
+
"globals": "^16.5.0",
|
|
38
|
+
"postcss": "^8.5.6",
|
|
39
|
+
"tailwindcss": "^3.4.19",
|
|
40
|
+
"typescript": "~5.9.3",
|
|
41
|
+
"typescript-eslint": "^8.46.4",
|
|
42
|
+
"vite": "^7.2.4"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
2
|
+
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
|
3
|
+
<line x1="16" y1="2" x2="16" y2="6"></line>
|
|
4
|
+
<line x1="8" y1="2" x2="8" y2="6"></line>
|
|
5
|
+
<line x1="3" y1="10" x2="21" y2="10"></line>
|
|
6
|
+
<path d="M8 14h.01"></path>
|
|
7
|
+
<path d="M12 14h.01"></path>
|
|
8
|
+
<path d="M16 14h.01"></path>
|
|
9
|
+
<path d="M8 18h.01"></path>
|
|
10
|
+
<path d="M12 18h.01"></path>
|
|
11
|
+
<path d="M16 18h.01"></path>
|
|
12
|
+
</svg>
|
package/src/App.css
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#root {
|
|
2
|
+
max-width: 1280px;
|
|
3
|
+
margin: 0 auto;
|
|
4
|
+
padding: 2rem;
|
|
5
|
+
text-align: center;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.logo {
|
|
9
|
+
height: 6em;
|
|
10
|
+
padding: 1.5em;
|
|
11
|
+
will-change: filter;
|
|
12
|
+
transition: filter 300ms;
|
|
13
|
+
}
|
|
14
|
+
.logo:hover {
|
|
15
|
+
filter: drop-shadow(0 0 2em #646cffaa);
|
|
16
|
+
}
|
|
17
|
+
.logo.react:hover {
|
|
18
|
+
filter: drop-shadow(0 0 2em #61dafbaa);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@keyframes logo-spin {
|
|
22
|
+
from {
|
|
23
|
+
transform: rotate(0deg);
|
|
24
|
+
}
|
|
25
|
+
to {
|
|
26
|
+
transform: rotate(360deg);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
31
|
+
a:nth-of-type(2) .logo {
|
|
32
|
+
animation: logo-spin infinite 20s linear;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.card {
|
|
37
|
+
padding: 2em;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.read-the-docs {
|
|
41
|
+
color: #888;
|
|
42
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { useState, useEffect } from 'react';
|
|
2
|
+
import { Calendar, Plus } from 'lucide-react';
|
|
3
|
+
import StatsCards from './components/StatsCards';
|
|
4
|
+
import JobFilters from './components/JobFilters';
|
|
5
|
+
import JobList from './components/JobList';
|
|
6
|
+
import JobDetails from './components/JobDetails';
|
|
7
|
+
import JobFormModal from './components/JobFormModal';
|
|
8
|
+
|
|
9
|
+
import { jobsApi } from './services/api';
|
|
10
|
+
import type { Job, JobStats } from './types/job';
|
|
11
|
+
|
|
12
|
+
function App() {
|
|
13
|
+
const [jobs, setJobs] = useState<Job[]>([]);
|
|
14
|
+
const [filteredJobs, setFilteredJobs] = useState<Job[]>([]);
|
|
15
|
+
const [stats, setStats] = useState<JobStats | null>(null);
|
|
16
|
+
|
|
17
|
+
// Pagination State
|
|
18
|
+
const [currentPage, setCurrentPage] = useState(1);
|
|
19
|
+
const [itemsPerPage, setItemsPerPage] = useState(10);
|
|
20
|
+
|
|
21
|
+
// Modal States
|
|
22
|
+
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
|
|
23
|
+
const [editingJob, setEditingJob] = useState<Job | null>(null);
|
|
24
|
+
const [isFormOpen, setIsFormOpen] = useState(false);
|
|
25
|
+
|
|
26
|
+
const [loading, setLoading] = useState(true);
|
|
27
|
+
const [filters, setFilters] = useState<{ status?: string; search?: string }>({});
|
|
28
|
+
|
|
29
|
+
// Fetch jobs
|
|
30
|
+
const fetchJobs = async () => {
|
|
31
|
+
try {
|
|
32
|
+
setLoading(true);
|
|
33
|
+
const data = await jobsApi.getJobs();
|
|
34
|
+
setJobs(data);
|
|
35
|
+
// We don't set filteredJobs here directly if we want to respect current filters
|
|
36
|
+
// But initial load or refresh usually resets or re-applies filters.
|
|
37
|
+
// let's rely on the useEffect dependency on 'jobs' to re-run the filter logic?
|
|
38
|
+
// Actually, the useEffect depends on [filters, jobs]. So setting jobs will trigger it.
|
|
39
|
+
// But we need to make sure we don't overwrite filteredJobs if filters exist.
|
|
40
|
+
// The useEffect below handles it.
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error('Failed to fetch jobs:', error);
|
|
43
|
+
// Mock data logic removed for brevity as it's not the focus of this diff and was just for demo
|
|
44
|
+
setJobs([]);
|
|
45
|
+
} finally {
|
|
46
|
+
setLoading(false);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ... (fetchStats same as before)
|
|
51
|
+
const fetchStats = async () => {
|
|
52
|
+
try {
|
|
53
|
+
const data = await jobsApi.getStats();
|
|
54
|
+
setStats(data);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Failed to fetch stats:', error);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Apply filters and Pagination Reset
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
let filtered = jobs;
|
|
63
|
+
|
|
64
|
+
if (filters.status) {
|
|
65
|
+
filtered = filtered.filter(job => job.status === filters.status);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (filters.search) {
|
|
69
|
+
filtered = filtered.filter(job =>
|
|
70
|
+
job.name.toLowerCase().includes(filters.search!.toLowerCase())
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
setFilteredJobs(filtered);
|
|
75
|
+
setCurrentPage(1); // Reset to page 1 on filter change
|
|
76
|
+
}, [filters, jobs]);
|
|
77
|
+
|
|
78
|
+
// Initial fetch
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
fetchJobs();
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
// Update stats when jobs change
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (jobs.length > 0) {
|
|
86
|
+
fetchStats();
|
|
87
|
+
}
|
|
88
|
+
}, [jobs]);
|
|
89
|
+
|
|
90
|
+
// Auto-refresh every 30 seconds
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
const interval = setInterval(fetchJobs, 30000);
|
|
93
|
+
return () => clearInterval(interval);
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
// ... (Handlers: handleDelete, handleRetry, handleCancel, handleSaveJob, openCreateModal, openEditModal - same as before)
|
|
97
|
+
const handleDelete = async (id: string) => {
|
|
98
|
+
if (!confirm('Are you sure you want to delete this job?')) return;
|
|
99
|
+
try {
|
|
100
|
+
await jobsApi.deleteJob(id);
|
|
101
|
+
fetchJobs();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
console.error('Failed to delete job:', error);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const handleRetry = async (id: string) => {
|
|
108
|
+
try {
|
|
109
|
+
await jobsApi.retryJob(id);
|
|
110
|
+
fetchJobs();
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.error('Failed to retry job:', error);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handleCancel = async (id: string) => {
|
|
117
|
+
if (!confirm('Are you sure you want to cancel this job?')) return;
|
|
118
|
+
try {
|
|
119
|
+
await jobsApi.cancelJob(id);
|
|
120
|
+
fetchJobs();
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error('Failed to cancel job:', error);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const handleSaveJob = async (jobData: Partial<Job>) => {
|
|
127
|
+
if (editingJob) {
|
|
128
|
+
await jobsApi.updateJob(editingJob._id, jobData);
|
|
129
|
+
} else {
|
|
130
|
+
await jobsApi.createJob(jobData);
|
|
131
|
+
}
|
|
132
|
+
fetchJobs();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const openCreateModal = () => {
|
|
136
|
+
setEditingJob(null);
|
|
137
|
+
setIsFormOpen(true);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const openEditModal = (job: Job) => {
|
|
141
|
+
setEditingJob(job);
|
|
142
|
+
setIsFormOpen(true);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// Pagination Logic
|
|
146
|
+
const totalPages = Math.ceil(filteredJobs.length / itemsPerPage);
|
|
147
|
+
const currentJobs = filteredJobs.slice(
|
|
148
|
+
(currentPage - 1) * itemsPerPage,
|
|
149
|
+
currentPage * itemsPerPage
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<div className="h-screen flex flex-col bg-gradient-to-br from-gray-900 via-gray-800 to-gray-900 overflow-hidden">
|
|
154
|
+
{/* Header */}
|
|
155
|
+
<header className="flex-none bg-gradient-to-r from-gray-900/95 to-gray-800/95 backdrop-blur-sm border-b border-gray-700/50 z-40">
|
|
156
|
+
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
|
157
|
+
<div className="flex items-center justify-between">
|
|
158
|
+
<div className="flex items-center gap-3">
|
|
159
|
+
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
|
160
|
+
<Calendar className="w-6 h-6 text-white" />
|
|
161
|
+
</div>
|
|
162
|
+
<div>
|
|
163
|
+
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">
|
|
164
|
+
Mongo Scheduler UI
|
|
165
|
+
</h1>
|
|
166
|
+
<p className="text-sm text-gray-400">Job Management Dashboard</p>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
<div className="flex items-center gap-4">
|
|
170
|
+
<div className="text-sm text-gray-400 hidden sm:block">
|
|
171
|
+
Auto-refresh: 30s
|
|
172
|
+
</div>
|
|
173
|
+
<button
|
|
174
|
+
onClick={openCreateModal}
|
|
175
|
+
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium shadow-lg shadow-blue-500/20"
|
|
176
|
+
>
|
|
177
|
+
<Plus className="w-4 h-4" />
|
|
178
|
+
Create Job
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</header>
|
|
184
|
+
|
|
185
|
+
{/* Main Content */}
|
|
186
|
+
<main className="flex-1 flex flex-col min-h-0 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full py-6 gap-6">
|
|
187
|
+
|
|
188
|
+
{/* Fixed Top Section: Stats & Filters */}
|
|
189
|
+
<div className="flex-none space-y-6">
|
|
190
|
+
<StatsCards stats={stats} />
|
|
191
|
+
<JobFilters onFilterChange={setFilters} />
|
|
192
|
+
</div>
|
|
193
|
+
|
|
194
|
+
{/* Scrollable List Section */}
|
|
195
|
+
<div className="flex-1 flex flex-col min-h-0 bg-gray-800/30 rounded-xl border border-gray-700/50 backdrop-blur-sm overflow-hidden">
|
|
196
|
+
|
|
197
|
+
<div className="flex-1 overflow-y-auto px-4 py-4 min-h-0 scrollbar-thin scrollbar-thumb-gray-700 scrollbar-track-transparent">
|
|
198
|
+
<JobList
|
|
199
|
+
jobs={currentJobs}
|
|
200
|
+
loading={loading}
|
|
201
|
+
onJobClick={setSelectedJob}
|
|
202
|
+
onEdit={openEditModal}
|
|
203
|
+
onRefresh={fetchJobs}
|
|
204
|
+
onDelete={handleDelete}
|
|
205
|
+
onRetry={handleRetry}
|
|
206
|
+
onCancel={handleCancel}
|
|
207
|
+
// Pagination Props
|
|
208
|
+
currentPage={currentPage}
|
|
209
|
+
totalPages={totalPages}
|
|
210
|
+
onPageChange={setCurrentPage}
|
|
211
|
+
itemsPerPage={itemsPerPage}
|
|
212
|
+
onItemsPerPageChange={setItemsPerPage}
|
|
213
|
+
totalItems={filteredJobs.length}
|
|
214
|
+
/>
|
|
215
|
+
</div>
|
|
216
|
+
</div>
|
|
217
|
+
</main>
|
|
218
|
+
|
|
219
|
+
{/* Job Details Modal */}
|
|
220
|
+
<JobDetails job={selectedJob} onClose={() => setSelectedJob(null)} />
|
|
221
|
+
|
|
222
|
+
{/* Create/Edit Job Modal */}
|
|
223
|
+
<JobFormModal
|
|
224
|
+
isOpen={isFormOpen}
|
|
225
|
+
onClose={() => setIsFormOpen(false)}
|
|
226
|
+
onSave={handleSaveJob}
|
|
227
|
+
job={editingJob}
|
|
228
|
+
/>
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export default App;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { X } from 'lucide-react';
|
|
2
|
+
import type { Job } from '../types/job';
|
|
3
|
+
import { formatDate, getStatusColor } from '../utils/helpers';
|
|
4
|
+
|
|
5
|
+
interface JobDetailsProps {
|
|
6
|
+
job: Job | null;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function JobDetails({ job, onClose }: JobDetailsProps) {
|
|
11
|
+
if (!job) return null;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
|
15
|
+
<div className="bg-gradient-to-br from-gray-900 to-gray-800 rounded-lg max-w-2xl w-full max-h-[90vh] overflow-hidden border border-gray-700">
|
|
16
|
+
{/* Header */}
|
|
17
|
+
<div className="flex items-center justify-between p-6 border-b border-gray-700">
|
|
18
|
+
<div>
|
|
19
|
+
<h2 className="text-2xl font-bold text-white">{job.name}</h2>
|
|
20
|
+
<span className={`inline-block mt-2 px-3 py-1 rounded text-sm border ${getStatusColor(job.status)}`}>
|
|
21
|
+
{job.status}
|
|
22
|
+
</span>
|
|
23
|
+
</div>
|
|
24
|
+
<button
|
|
25
|
+
onClick={onClose}
|
|
26
|
+
className="p-2 rounded-lg hover:bg-gray-700/50 transition-colors"
|
|
27
|
+
>
|
|
28
|
+
<X className="w-6 h-6 text-gray-400" />
|
|
29
|
+
</button>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
{/* Content */}
|
|
33
|
+
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)]">
|
|
34
|
+
<div className="space-y-6">
|
|
35
|
+
{/* Basic Info */}
|
|
36
|
+
<div>
|
|
37
|
+
<h3 className="text-sm font-semibold text-gray-400 uppercase mb-3">Basic Info</h3>
|
|
38
|
+
<div className="grid grid-cols-2 gap-4">
|
|
39
|
+
<InfoItem label="ID" value={job._id} />
|
|
40
|
+
<InfoItem label="Attempts" value={job.attempts.toString()} />
|
|
41
|
+
<InfoItem label="Created" value={formatDate(job.createdAt)} />
|
|
42
|
+
<InfoItem label="Updated" value={formatDate(job.updatedAt)} />
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
{/* Schedule */}
|
|
47
|
+
{(job.nextRunAt || job.lastRunAt) && (
|
|
48
|
+
<div>
|
|
49
|
+
<h3 className="text-sm font-semibold text-gray-400 uppercase mb-3">Schedule</h3>
|
|
50
|
+
<div className="grid grid-cols-2 gap-4">
|
|
51
|
+
{job.nextRunAt && <InfoItem label="Next Run" value={formatDate(job.nextRunAt)} />}
|
|
52
|
+
{job.lastRunAt && <InfoItem label="Last Run" value={formatDate(job.lastRunAt)} />}
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{/* Repeat */}
|
|
58
|
+
{job.repeat && (
|
|
59
|
+
<div>
|
|
60
|
+
<h3 className="text-sm font-semibold text-gray-400 uppercase mb-3">Repeat</h3>
|
|
61
|
+
<div className="bg-gray-800/50 rounded-lg p-4">
|
|
62
|
+
{job.repeat.cron && <InfoItem label="Cron" value={job.repeat.cron} />}
|
|
63
|
+
{job.repeat.every && <InfoItem label="Every" value={`${job.repeat.every}ms`} />}
|
|
64
|
+
{job.repeat.timezone && <InfoItem label="Timezone" value={job.repeat.timezone} />}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
|
|
69
|
+
{/* Retry */}
|
|
70
|
+
{job.retry && (
|
|
71
|
+
<div>
|
|
72
|
+
<h3 className="text-sm font-semibold text-gray-400 uppercase mb-3">Retry</h3>
|
|
73
|
+
<div className="bg-gray-800/50 rounded-lg p-4">
|
|
74
|
+
<InfoItem label="Max Attempts" value={job.retry.maxAttempts.toString()} />
|
|
75
|
+
<InfoItem label="Delay" value={typeof job.retry.delay === 'number' ? `${job.retry.delay}ms` : 'Dynamic'} />
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
{/* Lock Info */}
|
|
81
|
+
{job.lockedBy && (
|
|
82
|
+
<div>
|
|
83
|
+
<h3 className="text-sm font-semibold text-gray-400 uppercase mb-3">Lock Info</h3>
|
|
84
|
+
<div className="bg-gray-800/50 rounded-lg p-4">
|
|
85
|
+
<InfoItem label="Locked By" value={job.lockedBy} />
|
|
86
|
+
{job.lockedAt && <InfoItem label="Locked At" value={formatDate(job.lockedAt)} />}
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
|
|
91
|
+
{/* Error */}
|
|
92
|
+
{job.lastError && (
|
|
93
|
+
<div>
|
|
94
|
+
<h3 className="text-sm font-semibold text-gray-400 uppercase mb-3">Last Error</h3>
|
|
95
|
+
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-red-400 text-sm font-mono">
|
|
96
|
+
{job.lastError}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
|
|
101
|
+
{/* Data */}
|
|
102
|
+
{job.data && (
|
|
103
|
+
<div>
|
|
104
|
+
<h3 className="text-sm font-semibold text-gray-400 uppercase mb-3">Data</h3>
|
|
105
|
+
<pre className="bg-gray-800/50 rounded-lg p-4 text-sm text-gray-300 overflow-x-auto">
|
|
106
|
+
{JSON.stringify(job.data, null, 2)}
|
|
107
|
+
</pre>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function InfoItem({ label, value }: { label: string; value: string }) {
|
|
118
|
+
return (
|
|
119
|
+
<div className="mb-2">
|
|
120
|
+
<div className="text-xs text-gray-500 mb-1">{label}</div>
|
|
121
|
+
<div className="text-sm text-white">{value}</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Search, Filter } from 'lucide-react';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
|
|
4
|
+
interface JobFiltersProps {
|
|
5
|
+
onFilterChange: (filters: { status?: string; search?: string }) => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function JobFilters({ onFilterChange }: JobFiltersProps) {
|
|
9
|
+
const [search, setSearch] = useState('');
|
|
10
|
+
const [selectedStatus, setSelectedStatus] = useState<string>('all');
|
|
11
|
+
|
|
12
|
+
const handleSearchChange = (value: string) => {
|
|
13
|
+
setSearch(value);
|
|
14
|
+
onFilterChange({ status: selectedStatus === 'all' ? undefined : selectedStatus, search: value });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const handleStatusChange = (status: string) => {
|
|
18
|
+
setSelectedStatus(status);
|
|
19
|
+
onFilterChange({ status: status === 'all' ? undefined : status, search });
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const statuses = ['all', 'pending', 'running', 'completed', 'failed', 'cancelled'];
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<div className="mb-6 space-y-4">
|
|
26
|
+
{/* Search */}
|
|
27
|
+
<div className="relative">
|
|
28
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400" />
|
|
29
|
+
<input
|
|
30
|
+
type="text"
|
|
31
|
+
placeholder="Search jobs by name..."
|
|
32
|
+
value={search}
|
|
33
|
+
onChange={(e) => handleSearchChange(e.target.value)}
|
|
34
|
+
className="w-full pl-10 pr-4 py-3 bg-gray-800/50 border border-gray-700/50 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-blue-500/50 focus:ring-2 focus:ring-blue-500/20 transition-all"
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
{/* Status Filter */}
|
|
39
|
+
<div className="flex items-center gap-3">
|
|
40
|
+
<Filter className="w-5 h-5 text-gray-400" />
|
|
41
|
+
<div className="flex flex-wrap gap-2">
|
|
42
|
+
{statuses.map((status) => (
|
|
43
|
+
<button
|
|
44
|
+
key={status}
|
|
45
|
+
onClick={() => handleStatusChange(status)}
|
|
46
|
+
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all ${
|
|
47
|
+
selectedStatus === status
|
|
48
|
+
? 'bg-blue-500 text-white'
|
|
49
|
+
: 'bg-gray-800/50 text-gray-400 hover:bg-gray-700/50'
|
|
50
|
+
}`}
|
|
51
|
+
>
|
|
52
|
+
{status.charAt(0).toUpperCase() + status.slice(1)}
|
|
53
|
+
</button>
|
|
54
|
+
))}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|