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 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
+ ![Dashboard Preview](public/dashboard-preview.png)
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
@@ -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
+ }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
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
+ }