@lego-box/shell 1.0.5 → 1.0.6

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.
Files changed (45) hide show
  1. package/dist/emulator/lego-box-shell-1.0.6.tgz +0 -0
  2. package/package.json +2 -3
  3. package/src/auth/auth-store.ts +33 -0
  4. package/src/auth/auth.ts +176 -0
  5. package/src/components/ProtectedPage.tsx +48 -0
  6. package/src/config/env.node.ts +38 -0
  7. package/src/config/env.ts +103 -0
  8. package/src/context/AbilityContext.tsx +213 -0
  9. package/src/context/PiralInstanceContext.tsx +17 -0
  10. package/src/hooks/index.ts +11 -0
  11. package/src/hooks/useAuditLogs.ts +190 -0
  12. package/src/hooks/useDebounce.ts +34 -0
  13. package/src/hooks/usePermissionGuard.tsx +39 -0
  14. package/src/hooks/usePermissions.ts +190 -0
  15. package/src/hooks/useRoles.ts +233 -0
  16. package/src/hooks/useTickets.ts +214 -0
  17. package/src/hooks/useUserLogins.ts +39 -0
  18. package/src/hooks/useUsers.ts +252 -0
  19. package/src/index.html +16 -0
  20. package/src/index.tsx +296 -0
  21. package/src/layout.tsx +246 -0
  22. package/src/migrations/config.ts +62 -0
  23. package/src/migrations/dev-migrations.ts +75 -0
  24. package/src/migrations/index.ts +13 -0
  25. package/src/migrations/run-migrations.ts +187 -0
  26. package/src/migrations/runner.ts +925 -0
  27. package/src/migrations/types.ts +207 -0
  28. package/src/migrations/utils.ts +264 -0
  29. package/src/pages/AuditLogsPage.tsx +378 -0
  30. package/src/pages/ContactSupportPage.tsx +610 -0
  31. package/src/pages/LandingPage.tsx +221 -0
  32. package/src/pages/LoginPage.tsx +217 -0
  33. package/src/pages/MigrationsPage.tsx +1364 -0
  34. package/src/pages/ProfilePage.tsx +335 -0
  35. package/src/pages/SettingsPage.tsx +101 -0
  36. package/src/pages/SystemHealthCheckPage.tsx +144 -0
  37. package/src/pages/UserManagementPage.tsx +1010 -0
  38. package/src/piral/api.ts +39 -0
  39. package/src/piral/auth-casl.ts +56 -0
  40. package/src/piral/menu.ts +102 -0
  41. package/src/piral/piral.json +4 -0
  42. package/src/services/telemetry.ts +37 -0
  43. package/src/styles/globals.css +1351 -0
  44. package/src/utils/auditLogger.ts +68 -0
  45. package/dist/emulator/lego-box-shell-1.0.5.tgz +0 -0
@@ -0,0 +1,1364 @@
1
+ import * as React from 'react';
2
+ import { createPortal } from 'react-dom';
3
+ import {
4
+ StatsCard,
5
+ StatusBadge,
6
+ Tabs,
7
+ TabsList,
8
+ TabsTrigger,
9
+ Button,
10
+ SearchInput,
11
+ FilterChip,
12
+ Pagination,
13
+ ConsoleLog,
14
+ CanDisable,
15
+ } from '@lego-box/ui-kit';
16
+ import { usePiralInstance } from '../context/PiralInstanceContext';
17
+ import { useCan } from '../context/AbilityContext';
18
+ import type { Migration, MigrationHistory } from '../migrations/types';
19
+
20
+ /**
21
+ * Status filter options
22
+ */
23
+ const STATUS_OPTIONS = [
24
+ { value: 'all', label: 'All' },
25
+ { value: 'applied', label: 'Applied' },
26
+ { value: 'pending', label: 'Pending' },
27
+ { value: 'failed', label: 'Failed' },
28
+ { value: 'rolled_back', label: 'Rolled Back' },
29
+ ];
30
+
31
+ /**
32
+ * Get icon color class based on status
33
+ */
34
+ function getIconColorClass(status: Migration['status']) {
35
+ switch (status) {
36
+ case 'applied':
37
+ return 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400';
38
+ case 'pending':
39
+ return 'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400';
40
+ case 'failed':
41
+ return 'bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400';
42
+ case 'rolled_back':
43
+ return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
44
+ default:
45
+ return 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Get status type for StatusBadge component
51
+ */
52
+ function getStatusType(status: Migration['status']): 'success' | 'warning' | 'error' | 'info' {
53
+ switch (status) {
54
+ case 'applied':
55
+ return 'success';
56
+ case 'pending':
57
+ return 'warning';
58
+ case 'failed':
59
+ return 'error';
60
+ case 'rolled_back':
61
+ return 'info';
62
+ default:
63
+ return 'info';
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Format status for display
69
+ */
70
+ function formatStatus(status: string): string {
71
+ return status.split('_').map(word =>
72
+ word.charAt(0).toUpperCase() + word.slice(1)
73
+ ).join(' ');
74
+ }
75
+
76
+ /**
77
+ * Get row background class based on status
78
+ */
79
+ function getRowBgClass(status: Migration['status']) {
80
+ switch (status) {
81
+ case 'pending':
82
+ return 'bg-amber-50/30 dark:bg-amber-900/10 border-l-4 border-l-amber-400';
83
+ case 'failed':
84
+ return 'bg-red-50/30 dark:bg-red-900/10 border-l-4 border-l-red-400';
85
+ case 'rolled_back':
86
+ return 'bg-gray-50/30 dark:bg-gray-900/10 border-l-4 border-l-gray-400';
87
+ default:
88
+ return '';
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Format date for display
94
+ */
95
+ function formatDate(dateString: string | null): string {
96
+ if (!dateString) return 'Not executed';
97
+ const date = new Date(dateString);
98
+ return date.toLocaleString('en-US', {
99
+ month: 'short',
100
+ day: 'numeric',
101
+ year: 'numeric',
102
+ hour: '2-digit',
103
+ minute: '2-digit',
104
+ });
105
+ }
106
+
107
+ /**
108
+ * File Icon component
109
+ */
110
+ const FileIcon = ({ status }: { status: Migration['status'] }) => (
111
+ <div className={`w-10 h-10 rounded-lg flex items-center justify-center ${getIconColorClass(status)}`}>
112
+ <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
113
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
114
+ </svg>
115
+ </div>
116
+ );
117
+
118
+ /**
119
+ * Action buttons component based on status
120
+ */
121
+ const MigrationActions = ({
122
+ status,
123
+ onMigrate,
124
+ onRollback,
125
+ onRetry,
126
+ onView,
127
+ isLoading
128
+ }: {
129
+ status: Migration['status'];
130
+ onMigrate?: () => void;
131
+ onRollback?: () => void;
132
+ onRetry?: () => void;
133
+ onView?: () => void;
134
+ isLoading?: boolean;
135
+ }) => {
136
+ const buttonClasses = "inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed";
137
+
138
+ if (status === 'applied') {
139
+ return (
140
+ <div className="flex items-center justify-end gap-2">
141
+ <button
142
+ onClick={onView}
143
+ className={`${buttonClasses} text-foreground bg-muted hover:bg-muted/80`}
144
+ >
145
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
146
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
147
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
148
+ </svg>
149
+ View
150
+ </button>
151
+ </div>
152
+ );
153
+ }
154
+
155
+ if (status === 'pending') {
156
+ return (
157
+ <div className="flex items-center justify-end gap-2">
158
+ <button
159
+ onClick={onMigrate}
160
+ disabled={isLoading}
161
+ className={`${buttonClasses} text-white bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700`}
162
+ >
163
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
164
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
165
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
166
+ </svg>
167
+ Migrate
168
+ </button>
169
+ <button
170
+ onClick={onView}
171
+ className={`${buttonClasses} text-foreground bg-muted hover:bg-muted/80`}
172
+ >
173
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
174
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
175
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
176
+ </svg>
177
+ View
178
+ </button>
179
+ </div>
180
+ );
181
+ }
182
+
183
+ // Failed
184
+ return (
185
+ <div className="flex items-center justify-end gap-2">
186
+ <button
187
+ onClick={onRetry}
188
+ disabled={isLoading}
189
+ className={`${buttonClasses} text-white bg-blue-500 hover:bg-blue-600 dark:bg-blue-600 dark:hover:bg-blue-700`}
190
+ >
191
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
192
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
193
+ </svg>
194
+ Retry
195
+ </button>
196
+ <button
197
+ onClick={onView}
198
+ className={`${buttonClasses} text-foreground bg-muted hover:bg-muted/80`}
199
+ >
200
+ <svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
201
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
202
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
203
+ </svg>
204
+ View
205
+ </button>
206
+ </div>
207
+ );
208
+ };
209
+
210
+ /**
211
+ * View Migration Dialog Component
212
+ */
213
+ interface ViewMigrationDialogProps {
214
+ migration: Migration | null;
215
+ isOpen: boolean;
216
+ onClose: () => void;
217
+ }
218
+
219
+ const ViewMigrationDialog: React.FC<ViewMigrationDialogProps> = ({
220
+ migration,
221
+ isOpen,
222
+ onClose,
223
+ }) => {
224
+ if (!migration) return null;
225
+
226
+ const modalContent = (
227
+ <div
228
+ className={`dialog-overlay flex items-center justify-center ${
229
+ isOpen ? 'visible' : 'invisible'
230
+ }`}
231
+ >
232
+ {/* Backdrop - fixed to cover full viewport */}
233
+ <div
234
+ className={`fixed inset-0 bg-black/50 transition-opacity ${
235
+ isOpen ? 'opacity-100' : 'opacity-0'
236
+ }`}
237
+ style={{ zIndex: -1 }}
238
+ onClick={onClose}
239
+ />
240
+
241
+ {/* Dialog */}
242
+ <div
243
+ className={`relative z-10 bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-2xl w-full mx-4 max-h-[80vh] overflow-auto transition-all ${
244
+ isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
245
+ }`}
246
+ >
247
+ <div className="p-6">
248
+ <div className="flex items-center justify-between mb-4">
249
+ <h2 className="text-xl font-semibold text-foreground">Migration Details</h2>
250
+ <button
251
+ onClick={onClose}
252
+ className="text-muted-foreground hover:text-foreground transition-colors"
253
+ >
254
+ <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
255
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
256
+ </svg>
257
+ </button>
258
+ </div>
259
+
260
+ <div className="space-y-4">
261
+ <div className="grid grid-cols-2 gap-4">
262
+ <div>
263
+ <label className="text-xs font-medium text-muted-foreground uppercase">
264
+ Filename
265
+ </label>
266
+ <p className="text-sm font-mono text-foreground mt-1">{migration.filename}</p>
267
+ </div>
268
+ <div>
269
+ <label className="text-xs font-medium text-muted-foreground uppercase">
270
+ Name
271
+ </label>
272
+ <p className="text-sm text-foreground mt-1">{migration.name}</p>
273
+ </div>
274
+ </div>
275
+
276
+ <div className="grid grid-cols-2 gap-4">
277
+ <div>
278
+ <label className="text-xs font-medium text-muted-foreground uppercase">
279
+ Status
280
+ </label>
281
+ <div className="mt-1">
282
+ <StatusBadge status={getStatusType(migration.status)}>
283
+ {formatStatus(migration.status)}
284
+ </StatusBadge>
285
+ </div>
286
+ </div>
287
+ <div>
288
+ <label className="text-xs font-medium text-muted-foreground uppercase">
289
+ Timestamp
290
+ </label>
291
+ <p className="text-sm text-foreground mt-1">{migration.timestamp}</p>
292
+ </div>
293
+ </div>
294
+
295
+ <div className="grid grid-cols-2 gap-4">
296
+ <div>
297
+ <label className="text-xs font-medium text-muted-foreground uppercase">
298
+ Applied At
299
+ </label>
300
+ <p className="text-sm text-foreground mt-1">{formatDate(migration.appliedAt)}</p>
301
+ </div>
302
+ <div>
303
+ <label className="text-xs font-medium text-muted-foreground uppercase">
304
+ Batch
305
+ </label>
306
+ <p className="text-sm text-foreground mt-1">{migration.batch || 'N/A'}</p>
307
+ </div>
308
+ </div>
309
+
310
+ <div>
311
+ <label className="text-xs font-medium text-muted-foreground uppercase">
312
+ Checksum
313
+ </label>
314
+ <p className="text-sm font-mono text-foreground mt-1 break-all">{migration.checksum}</p>
315
+ </div>
316
+
317
+ {migration.errorMessage && (
318
+ <div>
319
+ <label className="text-xs font-medium text-red-500 uppercase">
320
+ Error Message
321
+ </label>
322
+ <div className="mt-1 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
323
+ <p className="text-sm text-red-600 dark:text-red-400">{migration.errorMessage}</p>
324
+ </div>
325
+ </div>
326
+ )}
327
+ </div>
328
+
329
+ <div className="mt-6 flex justify-end">
330
+ <Button onClick={onClose}>Close</Button>
331
+ </div>
332
+ </div>
333
+ </div>
334
+ </div>
335
+ );
336
+
337
+ return createPortal(modalContent, document.body);
338
+ };
339
+
340
+ /**
341
+ * Rollback Confirmation Dialog Component
342
+ */
343
+ interface RollbackDialogProps {
344
+ migration: Migration | null;
345
+ isOpen: boolean;
346
+ onClose: () => void;
347
+ onConfirm: () => void;
348
+ isLoading: boolean;
349
+ }
350
+
351
+ const RollbackDialog: React.FC<RollbackDialogProps> = ({
352
+ migration,
353
+ isOpen,
354
+ onClose,
355
+ onConfirm,
356
+ isLoading,
357
+ }) => {
358
+ if (!migration) return null;
359
+
360
+ const modalContent = (
361
+ <div
362
+ className={`dialog-overlay flex items-center justify-center ${
363
+ isOpen ? 'visible' : 'invisible'
364
+ }`}
365
+ >
366
+ {/* Backdrop - fixed to cover full viewport */}
367
+ <div
368
+ className={`fixed inset-0 bg-black/50 transition-opacity ${
369
+ isOpen ? 'opacity-100' : 'opacity-0'
370
+ }`}
371
+ style={{ zIndex: -1 }}
372
+ onClick={onClose}
373
+ />
374
+
375
+ {/* Dialog */}
376
+ <div
377
+ className={`relative z-10 bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-md w-full mx-4 transition-all ${
378
+ isOpen ? 'scale-100 opacity-100' : 'scale-95 opacity-0'
379
+ }`}
380
+ >
381
+ <div className="p-6">
382
+ <div className="flex items-center gap-3 mb-4">
383
+ <div className="w-10 h-10 rounded-full bg-amber-100 dark:bg-amber-900/30 flex items-center justify-center">
384
+ <svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
385
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
386
+ </svg>
387
+ </div>
388
+ <h2 className="text-xl font-semibold text-foreground">Confirm Rollback</h2>
389
+ </div>
390
+
391
+ <p className="text-muted-foreground mb-4">
392
+ Are you sure you want to rollback this migration? This action cannot be undone.
393
+ </p>
394
+
395
+ <div className="bg-muted dark:bg-slate-700/50 rounded-lg p-3 mb-6">
396
+ <p className="text-sm font-mono text-foreground">{migration.filename}</p>
397
+ </div>
398
+
399
+ <div className="flex gap-3 justify-end">
400
+ <Button variant="outline" onClick={onClose} disabled={isLoading}>
401
+ Cancel
402
+ </Button>
403
+ <Button variant="destructive" onClick={onConfirm} disabled={isLoading}>
404
+ {isLoading ? (
405
+ <>
406
+ <svg className="animate-spin -ml-1 mr-2 h-4 w-4" fill="none" viewBox="0 0 24 24">
407
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
408
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
409
+ </svg>
410
+ Rolling back...
411
+ </>
412
+ ) : (
413
+ 'Rollback Migration'
414
+ )}
415
+ </Button>
416
+ </div>
417
+ </div>
418
+ </div>
419
+ </div>
420
+ );
421
+
422
+ return createPortal(modalContent, document.body);
423
+ };
424
+
425
+ /**
426
+ * Virtual scroll list component for logs
427
+ */
428
+ interface VirtualScrollListProps {
429
+ items: { text: string; type: 'command' | 'info' | 'success' | 'error' }[];
430
+ itemHeight: number;
431
+ containerHeight: number;
432
+ renderItem: (item: { text: string; type: 'command' | 'info' | 'success' | 'error' }, index: number) => React.ReactNode;
433
+ onLoadMore?: () => void;
434
+ hasMore?: boolean;
435
+ }
436
+
437
+ const VirtualScrollList: React.FC<VirtualScrollListProps> = ({
438
+ items,
439
+ itemHeight,
440
+ containerHeight,
441
+ renderItem,
442
+ onLoadMore,
443
+ hasMore = false,
444
+ }) => {
445
+ const containerRef = React.useRef<HTMLDivElement>(null);
446
+ const [scrollTop, setScrollTop] = React.useState(0);
447
+ const [loadingMore, setLoadingMore] = React.useState(false);
448
+
449
+ const totalHeight = items.length * itemHeight;
450
+ const startIndex = Math.floor(scrollTop / itemHeight);
451
+ const endIndex = Math.min(
452
+ items.length,
453
+ Math.ceil((scrollTop + containerHeight) / itemHeight)
454
+ );
455
+ const visibleItems = items.slice(startIndex, endIndex);
456
+ const offsetY = startIndex * itemHeight;
457
+
458
+ const handleScroll = React.useCallback((e: React.UIEvent<HTMLDivElement>) => {
459
+ const newScrollTop = e.currentTarget.scrollTop;
460
+ setScrollTop(newScrollTop);
461
+
462
+ // Load more when scrolling near the bottom
463
+ if (
464
+ onLoadMore &&
465
+ hasMore &&
466
+ !loadingMore &&
467
+ newScrollTop + containerHeight >= totalHeight - containerHeight
468
+ ) {
469
+ setLoadingMore(true);
470
+ onLoadMore();
471
+ setTimeout(() => setLoadingMore(false), 500);
472
+ }
473
+ }, [onLoadMore, hasMore, loadingMore, totalHeight, containerHeight]);
474
+
475
+ return (
476
+ <div
477
+ ref={containerRef}
478
+ onScroll={handleScroll}
479
+ style={{ height: containerHeight, overflow: 'auto' }}
480
+ >
481
+ <div style={{ height: totalHeight, position: 'relative' }}>
482
+ <div style={{ transform: `translateY(${offsetY}px)` }}>
483
+ {visibleItems.map((item, index) => renderItem(item, startIndex + index))}
484
+ </div>
485
+ {loadingMore && (
486
+ <div className="py-2 text-center text-muted-foreground text-sm">
487
+ Loading more...
488
+ </div>
489
+ )}
490
+ </div>
491
+ </div>
492
+ );
493
+ };
494
+
495
+ /**
496
+ * Migrations Management Page
497
+ *
498
+ * Features:
499
+ * - Real-time migration data from PocketBase
500
+ * - Stats overview cards for migration counts
501
+ * - Tab navigation for Migrations, History, and Console Logs
502
+ * - Data table with migration files and status
503
+ * - Action buttons for each migration (Migrate, Rollback, Retry, View)
504
+ * - Migration history view
505
+ * - Console logs section at the bottom
506
+ */
507
+ export function MigrationsPage() {
508
+ const instance = usePiralInstance();
509
+ const can = useCan();
510
+ const [activeTab, setActiveTab] = React.useState('migrations');
511
+ const [activeFilter, setActiveFilter] = React.useState('all');
512
+ const [searchQuery, setSearchQuery] = React.useState('');
513
+ const [migrationsPage, setMigrationsPage] = React.useState(1);
514
+ const [migrationsPerPage, setMigrationsPerPage] = React.useState(10);
515
+ const [historySearchQuery, setHistorySearchQuery] = React.useState('');
516
+ const [historyPage, setHistoryPage] = React.useState(1);
517
+ const [historyPerPage, setHistoryPerPage] = React.useState(10);
518
+ const [migrations, setMigrations] = React.useState<Migration[]>([]);
519
+ const [history, setHistory] = React.useState<MigrationHistory[]>([]);
520
+ const [logs, setLogs] = React.useState<{text: string; type: 'command' | 'info' | 'success' | 'error'}[]>([]);
521
+ const [loading, setLoading] = React.useState(false);
522
+ const [actionLoading, setActionLoading] = React.useState<string | null>(null);
523
+ const [isAuthenticated, setIsAuthenticated] = React.useState(false);
524
+
525
+ // Dialog states
526
+ const [viewDialogOpen, setViewDialogOpen] = React.useState(false);
527
+ const [selectedMigration, setSelectedMigration] = React.useState<Migration | null>(null);
528
+ const [rollbackDialogOpen, setRollbackDialogOpen] = React.useState(false);
529
+ const [rollbackLoading, setRollbackLoading] = React.useState(false);
530
+
531
+ // Log pagination state
532
+ const [logPage, setLogPage] = React.useState(1);
533
+ const [hasMoreLogs, setHasMoreLogs] = React.useState(true);
534
+ const LOGS_PER_PAGE = 50;
535
+ const sessionId = React.useMemo(() => `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, []);
536
+
537
+ // Get PocketBase instance from Piral context
538
+ const pb = React.useMemo(() => {
539
+ return (instance as any)?.root?.pocketbase;
540
+ }, [instance]);
541
+
542
+ // Check authentication status
543
+ React.useEffect(() => {
544
+ if (pb) {
545
+ setIsAuthenticated(pb.authStore.isValid);
546
+
547
+ // Listen for auth changes
548
+ const unsubscribe = pb.authStore.onChange((token: string, model: any) => {
549
+ setIsAuthenticated(!!token);
550
+ });
551
+
552
+ return () => unsubscribe();
553
+ }
554
+ }, [pb]);
555
+
556
+ // Add log entry and persist to database
557
+ const addLog = async (text: string, type: 'command' | 'info' | 'success' | 'error' = 'info') => {
558
+ const logEntry = { text, type };
559
+ setLogs(prev => [...prev, logEntry]);
560
+
561
+ // Persist to database
562
+ if (pb && pb.authStore.isValid) {
563
+ try {
564
+ // Disable auto-cancellation for log persistence
565
+ await pb.collection('migration_logs').create({
566
+ message: text,
567
+ type: type,
568
+ timestamp: new Date().toISOString(),
569
+ sessionId: sessionId,
570
+ }, { $autoCancel: false });
571
+ } catch (error) {
572
+ console.error('Failed to persist log:', error);
573
+ }
574
+ }
575
+ };
576
+
577
+ // Load logs from database with pagination
578
+ const loadLogsFromDatabase = React.useCallback(async (page: number = 1) => {
579
+ if (!pb) return;
580
+
581
+ try {
582
+ const result = await pb.collection('migration_logs').getList(page, LOGS_PER_PAGE, {
583
+ sort: '-timestamp',
584
+ // Remove sessionId filter to show ALL logs from database
585
+ });
586
+
587
+ const loadedLogs = result.items.map((record: any) => ({
588
+ text: record.message,
589
+ type: record.type,
590
+ }));
591
+
592
+ // Show newest first (don't reverse)
593
+ if (page === 1) {
594
+ setLogs(loadedLogs);
595
+ } else {
596
+ setLogs(prev => [...prev, ...loadedLogs]);
597
+ }
598
+
599
+ setHasMoreLogs(result.items.length === LOGS_PER_PAGE);
600
+ setLogPage(page);
601
+ } catch (error) {
602
+ console.error('Failed to load logs:', error);
603
+ }
604
+ }, [pb]);
605
+
606
+ // Load more logs (for virtual scroll)
607
+ const handleLoadMoreLogs = React.useCallback(() => {
608
+ if (hasMoreLogs && !loading) {
609
+ loadLogsFromDatabase(logPage + 1);
610
+ }
611
+ }, [hasMoreLogs, loading, logPage, loadLogsFromDatabase]);
612
+
613
+ // Fetch migrations
614
+ const fetchMigrations = React.useCallback(async () => {
615
+ if (!pb) {
616
+ console.log('[Migrations] No PocketBase instance');
617
+ return;
618
+ }
619
+
620
+ console.log('[Migrations] Fetching migrations...');
621
+
622
+ try {
623
+ setLoading(true);
624
+ addLog('Fetching migrations...', 'info');
625
+
626
+ const records = await pb.collection('migrations').getFullList({
627
+ sort: 'timestamp',
628
+ });
629
+
630
+ console.log('[Migrations] Raw records:', records);
631
+
632
+ const formattedMigrations: Migration[] = records.map((record: any) => ({
633
+ id: record.id,
634
+ filename: record.filename,
635
+ timestamp: record.timestamp.toString(),
636
+ name: record.name,
637
+ status: record.status,
638
+ appliedAt: record.appliedAt,
639
+ errorMessage: record.errorMessage,
640
+ checksum: record.checksum,
641
+ batch: record.batch,
642
+ }));
643
+
644
+ console.log('[Migrations] Formatted migrations:', formattedMigrations);
645
+
646
+ setMigrations(formattedMigrations);
647
+ addLog(`Found ${formattedMigrations.length} migrations`, 'success');
648
+ } catch (error: any) {
649
+ console.error('[Migrations] Error:', error);
650
+ addLog(`Error fetching migrations: ${error.message}`, 'error');
651
+ } finally {
652
+ setLoading(false);
653
+ }
654
+ }, [pb]);
655
+
656
+ // Fetch migration history
657
+ const fetchHistory = React.useCallback(async () => {
658
+ if (!pb) return;
659
+
660
+ try {
661
+ const records = await pb.collection('migrations_history').getFullList({
662
+ sort: '-executedAt',
663
+ expand: 'migrationId',
664
+ });
665
+
666
+ const formattedHistory: MigrationHistory[] = records.map((record: any) => ({
667
+ id: record.id,
668
+ migrationId: record.migrationId,
669
+ action: record.action,
670
+ executedAt: record.executedAt,
671
+ executedBy: record.executedBy,
672
+ errorMessage: record.errorMessage,
673
+ duration: record.duration,
674
+ }));
675
+
676
+ setHistory(formattedHistory);
677
+ } catch (error: any) {
678
+ addLog(`Error fetching history: ${error.message}`, 'error');
679
+ }
680
+ }, [pb]);
681
+
682
+ // Run all pending migrations
683
+ const handleMigrateAll = async () => {
684
+ if (!pb) {
685
+ addLog('PocketBase not available', 'error');
686
+ return;
687
+ }
688
+
689
+ try {
690
+ setLoading(true);
691
+ addLog('> Running migrations...', 'command');
692
+
693
+ const response = await fetch('/api/migrations/run', {
694
+ method: 'POST',
695
+ headers: { 'Content-Type': 'application/json' },
696
+ });
697
+
698
+ if (response.ok) {
699
+ const result = await response.json();
700
+ addLog(`Migrations complete: ${result.applied} applied, ${result.failed} failed`, result.success ? 'success' : 'error');
701
+ await fetchMigrations();
702
+ await fetchHistory();
703
+ } else {
704
+ addLog('Failed to run migrations', 'error');
705
+ }
706
+ } catch (error: any) {
707
+ addLog(`Error: ${error.message}`, 'error');
708
+ } finally {
709
+ setLoading(false);
710
+ }
711
+ };
712
+
713
+ // Run single migration
714
+ const handleMigrate = async (migrationId: string) => {
715
+ // For now, run all pending migrations
716
+ await handleMigrateAll();
717
+ };
718
+
719
+ // Open rollback confirmation dialog
720
+ const handleRollbackClick = (migration: Migration) => {
721
+ setSelectedMigration(migration);
722
+ setRollbackDialogOpen(true);
723
+ };
724
+
725
+ // Execute rollback
726
+ const handleRollbackConfirm = async () => {
727
+ if (!pb || !selectedMigration) return;
728
+
729
+ try {
730
+ setRollbackLoading(true);
731
+ addLog(`> Rolling back ${selectedMigration.filename}...`, 'command');
732
+
733
+ const response = await fetch('/api/migrations/rollback', {
734
+ method: 'POST',
735
+ headers: { 'Content-Type': 'application/json' },
736
+ body: JSON.stringify({ migrationId: selectedMigration.id }),
737
+ });
738
+
739
+ // Get response as text first to handle non-JSON responses
740
+ const responseText = await response.text();
741
+
742
+ if (response.ok) {
743
+ try {
744
+ const result = JSON.parse(responseText);
745
+ addLog(`Rollback complete: ${result.message || 'Success'}`, 'success');
746
+ await fetchMigrations();
747
+ await fetchHistory();
748
+ } catch (parseError) {
749
+ // If JSON parsing fails but response was ok, still consider it success
750
+ addLog('Rollback completed successfully', 'success');
751
+ await fetchMigrations();
752
+ await fetchHistory();
753
+ }
754
+ } else {
755
+ // Handle error response
756
+ let errorMessage = 'Unknown error';
757
+ try {
758
+ const error = JSON.parse(responseText);
759
+ errorMessage = error.message || `HTTP ${response.status}`;
760
+ } catch {
761
+ // If not JSON, use status text or raw text
762
+ errorMessage = response.statusText || responseText || `HTTP ${response.status}`;
763
+ }
764
+
765
+ if (response.status === 404) {
766
+ addLog('Rollback endpoint not found. Backend API not implemented yet.', 'error');
767
+ } else {
768
+ addLog(`Rollback failed: ${errorMessage}`, 'error');
769
+ }
770
+ }
771
+ } catch (error: any) {
772
+ addLog(`Rollback error: ${error.message}`, 'error');
773
+ } finally {
774
+ setRollbackLoading(false);
775
+ setRollbackDialogOpen(false);
776
+ setSelectedMigration(null);
777
+ }
778
+ };
779
+
780
+ // Retry failed migration
781
+ const handleRetry = async (migrationId: string) => {
782
+ await handleMigrateAll();
783
+ };
784
+
785
+ // Open view dialog
786
+ const handleViewClick = (migration: Migration) => {
787
+ setSelectedMigration(migration);
788
+ setViewDialogOpen(true);
789
+ };
790
+
791
+ // Clear logs
792
+ const handleClearLogs = () => {
793
+ setLogs([]);
794
+ };
795
+
796
+ // Copy logs
797
+ const handleCopyLogs = () => {
798
+ const logText = logs.map(log => log.text).join('\n');
799
+ navigator.clipboard.writeText(logText);
800
+ addLog('Logs copied to clipboard', 'success');
801
+ };
802
+
803
+ // Refresh data - only refresh the current tab
804
+ const handleRefresh = async () => {
805
+ switch (activeTab) {
806
+ case 'migrations':
807
+ await fetchMigrations();
808
+ break;
809
+ case 'history':
810
+ await fetchHistory();
811
+ break;
812
+ case 'logs':
813
+ await loadLogsFromDatabase(1);
814
+ break;
815
+ }
816
+ addLog('Refreshed', 'success');
817
+ };
818
+
819
+ // Debug: log when migrations change
820
+ React.useEffect(() => {
821
+ console.log('[Migrations] Current migrations state:', migrations);
822
+ console.log('[Migrations] Current history state:', history);
823
+ console.log('[Migrations] isAuthenticated:', isAuthenticated);
824
+ }, [migrations, history, isAuthenticated]);
825
+
826
+ // Track which tabs have been loaded to avoid unnecessary refetching
827
+ const loadedTabsRef = React.useRef<Set<string>>(new Set());
828
+
829
+ // Initial load - only fetch data for the active tab
830
+ React.useEffect(() => {
831
+ console.log('[Migrations] Initial load effect - pb:', !!pb, 'isAuthenticated:', isAuthenticated, 'activeTab:', activeTab);
832
+ if (!pb || !isAuthenticated) return;
833
+
834
+ // Only fetch data for the currently active tab
835
+ switch (activeTab) {
836
+ case 'migrations':
837
+ if (!loadedTabsRef.current.has('migrations')) {
838
+ fetchMigrations();
839
+ loadedTabsRef.current.add('migrations');
840
+ }
841
+ break;
842
+ case 'history':
843
+ if (!loadedTabsRef.current.has('history')) {
844
+ fetchHistory();
845
+ loadedTabsRef.current.add('history');
846
+ }
847
+ break;
848
+ case 'logs':
849
+ if (!loadedTabsRef.current.has('logs')) {
850
+ loadLogsFromDatabase(1);
851
+ loadedTabsRef.current.add('logs');
852
+ }
853
+ break;
854
+ }
855
+ }, [pb, isAuthenticated, activeTab, fetchMigrations, fetchHistory, loadLogsFromDatabase]);
856
+
857
+ // Stats calculations
858
+ const stats = React.useMemo(() => {
859
+ const total = migrations.length;
860
+ const applied = migrations.filter((m) => m.status === 'applied').length;
861
+ const pending = migrations.filter((m) => m.status === 'pending').length;
862
+ const failed = migrations.filter((m) => m.status === 'failed').length;
863
+ const rolledBack = migrations.filter((m) => m.status === 'rolled_back').length;
864
+ return { total, applied, pending, failed, rolledBack };
865
+ }, [migrations]);
866
+
867
+ // Filter migrations based on search and active filter
868
+ const filteredMigrations = React.useMemo(() => {
869
+ let filtered = migrations;
870
+
871
+ if (searchQuery) {
872
+ const query = searchQuery.toLowerCase();
873
+ filtered = filtered.filter(
874
+ (migration) =>
875
+ migration.filename.toLowerCase().includes(query) ||
876
+ migration.name.toLowerCase().includes(query)
877
+ );
878
+ }
879
+
880
+ if (activeFilter !== 'all') {
881
+ filtered = filtered.filter(
882
+ (migration) => migration.status === activeFilter
883
+ );
884
+ }
885
+
886
+ return filtered;
887
+ }, [migrations, searchQuery, activeFilter]);
888
+
889
+ // Client-side pagination for migrations
890
+ const paginatedMigrations = React.useMemo(() => {
891
+ const start = (migrationsPage - 1) * migrationsPerPage;
892
+ return filteredMigrations.slice(start, start + migrationsPerPage);
893
+ }, [filteredMigrations, migrationsPage, migrationsPerPage]);
894
+
895
+ const migrationsTotalPages = Math.max(1, Math.ceil(filteredMigrations.length / migrationsPerPage));
896
+
897
+ const filteredHistory = React.useMemo(() => {
898
+ if (!historySearchQuery.trim()) return history;
899
+ const q = historySearchQuery.toLowerCase();
900
+ return history.filter(
901
+ (h) =>
902
+ (h.executedBy || '').toLowerCase().includes(q) ||
903
+ (h.action || '').toLowerCase().includes(q)
904
+ );
905
+ }, [history, historySearchQuery]);
906
+
907
+ const paginatedHistory = React.useMemo(() => {
908
+ const start = (historyPage - 1) * historyPerPage;
909
+ return filteredHistory.slice(start, start + historyPerPage);
910
+ }, [filteredHistory, historyPage, historyPerPage]);
911
+
912
+ const historyTotalPages = Math.max(1, Math.ceil(filteredHistory.length / historyPerPage));
913
+
914
+ React.useEffect(() => {
915
+ setMigrationsPage(1);
916
+ }, [searchQuery, activeFilter]);
917
+
918
+ React.useEffect(() => {
919
+ setHistoryPage(1);
920
+ }, [historySearchQuery]);
921
+
922
+ if (!pb) {
923
+ return (
924
+ <div className="p-8 text-center">
925
+ <p className="text-muted-foreground">Loading...</p>
926
+ </div>
927
+ );
928
+ }
929
+
930
+ return (
931
+ <div className="space-y-6">
932
+ {/* Header */}
933
+ <div className="page-header">
934
+ <div className="page-header-left">
935
+ <h1 className="text-2xl font-bold text-foreground">Migrations</h1>
936
+ <p className="text-sm text-muted-foreground mt-1">
937
+ Manage your PocketBase database migrations
938
+ </p>
939
+ </div>
940
+ <div className="page-header-right">
941
+ <Button
942
+ variant="success"
943
+ onClick={handleMigrateAll}
944
+ disabled={loading || stats.pending === 0}
945
+ >
946
+ <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
947
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
948
+ </svg>
949
+ Migrate All ({stats.pending})
950
+ </Button>
951
+ <Button variant="outline" onClick={handleRefresh} disabled={loading}>
952
+ <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
953
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
954
+ </svg>
955
+ Refresh
956
+ </Button>
957
+ </div>
958
+ </div>
959
+
960
+ {/* Tabs */}
961
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
962
+ <TabsList>
963
+ <CanDisable allowed={can('read', 'migrations')}>
964
+ <TabsTrigger value="migrations" badge={stats.total}>
965
+ <svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
966
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
967
+ </svg>
968
+ Migrations
969
+ </TabsTrigger>
970
+ </CanDisable>
971
+ <CanDisable allowed={can('read', 'migrations_history')}>
972
+ <TabsTrigger value="history" badge={history.length}>
973
+ <svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
974
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
975
+ </svg>
976
+ History
977
+ </TabsTrigger>
978
+ </CanDisable>
979
+ <CanDisable allowed={can('read', 'migration_logs')}>
980
+ <TabsTrigger value="logs">
981
+ <svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
982
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
983
+ </svg>
984
+ Console Logs
985
+ </TabsTrigger>
986
+ </CanDisable>
987
+ </TabsList>
988
+ </Tabs>
989
+
990
+ {/* Stats Cards */}
991
+ <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '1rem' }}>
992
+ <StatsCard
993
+ value={stats.total}
994
+ label="Total Migrations"
995
+ icon={
996
+ <svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
997
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
998
+ </svg>
999
+ }
1000
+ className="border-l-4 border-l-blue-500"
1001
+ />
1002
+ <StatsCard
1003
+ value={stats.applied}
1004
+ label="Applied"
1005
+ icon={
1006
+ <svg className="w-5 h-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1007
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
1008
+ </svg>
1009
+ }
1010
+ className="border-l-4 border-l-emerald-500"
1011
+ />
1012
+ <StatsCard
1013
+ value={stats.pending}
1014
+ label="Pending"
1015
+ icon={
1016
+ <svg className="w-5 h-5 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1017
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
1018
+ </svg>
1019
+ }
1020
+ className="border-l-4 border-l-amber-500"
1021
+ />
1022
+ <StatsCard
1023
+ value={stats.failed}
1024
+ label="Failed"
1025
+ icon={
1026
+ <svg className="w-5 h-5 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1027
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
1028
+ </svg>
1029
+ }
1030
+ className="border-l-4 border-l-red-500"
1031
+ />
1032
+ </div>
1033
+
1034
+ {/* Migrations Table Card */}
1035
+ {activeTab === 'migrations' && (
1036
+ <div className="space-y-4">
1037
+ <div className="flex flex-col sm:flex-row gap-4 sm:items-center">
1038
+ <div className="flex-1 w-full min-w-0">
1039
+ <SearchInput
1040
+ placeholder="Search migrations..."
1041
+ value={searchQuery}
1042
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
1043
+ icon={
1044
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1045
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
1046
+ </svg>
1047
+ }
1048
+ />
1049
+ </div>
1050
+ <div className="flex items-center gap-2 flex-shrink-0">
1051
+ <Button
1052
+ variant="outline"
1053
+ size="sm"
1054
+ onClick={() => {
1055
+ const headers = ['Filename', 'Name', 'Status', 'Applied At', 'Batch'];
1056
+ const rows = filteredMigrations.map((m) => [
1057
+ m.filename,
1058
+ m.name,
1059
+ m.status,
1060
+ m.appliedAt || 'Not executed',
1061
+ String(m.batch || ''),
1062
+ ]);
1063
+ const csv = [headers.join(','), ...rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(','))].join('\n');
1064
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
1065
+ const link = document.createElement('a');
1066
+ link.href = URL.createObjectURL(blob);
1067
+ link.download = `migrations-export-${new Date().toISOString().slice(0, 10)}.csv`;
1068
+ link.click();
1069
+ URL.revokeObjectURL(link.href);
1070
+ }}
1071
+ className="gap-2"
1072
+ >
1073
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1074
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
1075
+ </svg>
1076
+ Export
1077
+ </Button>
1078
+ </div>
1079
+ </div>
1080
+
1081
+ <div className="flex flex-wrap gap-2">
1082
+ <FilterChip active={activeFilter === 'all'} count={stats.total} onClick={() => setActiveFilter('all')}>
1083
+ All
1084
+ </FilterChip>
1085
+ <FilterChip active={activeFilter === 'applied'} count={stats.applied} onClick={() => setActiveFilter('applied')}>
1086
+ Applied
1087
+ </FilterChip>
1088
+ <FilterChip active={activeFilter === 'pending'} count={stats.pending} onClick={() => setActiveFilter('pending')}>
1089
+ Pending
1090
+ </FilterChip>
1091
+ <FilterChip active={activeFilter === 'failed'} count={stats.failed} onClick={() => setActiveFilter('failed')}>
1092
+ Failed
1093
+ </FilterChip>
1094
+ <FilterChip active={activeFilter === 'rolled_back'} count={stats.rolledBack} onClick={() => setActiveFilter('rolled_back')}>
1095
+ Rolled Back
1096
+ </FilterChip>
1097
+ </div>
1098
+
1099
+ <div className="bg-card dark:bg-slate-800/50 rounded-2xl border border-border dark:border-slate-700 overflow-hidden">
1100
+ {/* Table */}
1101
+ <div className="overflow-x-auto">
1102
+ <table className="w-full">
1103
+ <thead>
1104
+ <tr className="bg-muted/50 dark:bg-slate-800">
1105
+ <th className="text-left text-xs font-medium text-muted-foreground uppercase tracking-wider px-6 py-3">
1106
+ Migration File
1107
+ </th>
1108
+ <th className="text-left text-xs font-medium text-muted-foreground uppercase tracking-wider px-6 py-3 w-32">
1109
+ Status
1110
+ </th>
1111
+ <th className="text-left text-xs font-medium text-muted-foreground uppercase tracking-wider px-6 py-3 w-48">
1112
+ Last Executed
1113
+ </th>
1114
+ <th className="text-left text-xs font-medium text-muted-foreground uppercase tracking-wider px-6 py-3 w-48">
1115
+ Actions
1116
+ </th>
1117
+ </tr>
1118
+ </thead>
1119
+ <tbody className="divide-y divide-border dark:divide-slate-700">
1120
+ {!isAuthenticated ? (
1121
+ <tr>
1122
+ <td colSpan={4} className="px-6 py-8 text-center">
1123
+ <div className="text-muted-foreground">
1124
+ <svg className="w-12 h-12 mx-auto mb-3 text-muted-foreground/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1125
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
1126
+ </svg>
1127
+ <p className="text-lg font-medium">Authentication Required</p>
1128
+ <p className="text-sm mt-1">Please log in to view migrations</p>
1129
+ </div>
1130
+ </td>
1131
+ </tr>
1132
+ ) : loading && migrations.length === 0 ? (
1133
+ <tr>
1134
+ <td colSpan={4} className="px-6 py-8 text-center text-muted-foreground">
1135
+ Loading migrations...
1136
+ </td>
1137
+ </tr>
1138
+ ) : filteredMigrations.length === 0 ? (
1139
+ <tr>
1140
+ <td colSpan={4} className="px-6 py-8 text-center text-muted-foreground">
1141
+ {loading ? 'Loading migrations...' : 'No migrations found'}
1142
+ </td>
1143
+ </tr>
1144
+ ) : (
1145
+ paginatedMigrations.map((migration) => (
1146
+ <tr
1147
+ key={migration.id}
1148
+ className={`hover:bg-muted/30 dark:hover:bg-slate-800/50 transition-colors ${getRowBgClass(migration.status)}`}
1149
+ >
1150
+ <td className="px-6 py-4">
1151
+ <div className="flex items-center gap-3">
1152
+ <FileIcon status={migration.status} />
1153
+ <div className="flex flex-col">
1154
+ <span className="text-sm font-medium text-foreground dark:text-slate-200 font-mono">
1155
+ {migration.filename}
1156
+ </span>
1157
+ {migration.errorMessage ? (
1158
+ <span className="text-xs text-red-500 dark:text-red-400">
1159
+ {migration.errorMessage}
1160
+ </span>
1161
+ ) : (
1162
+ <span className="text-xs text-muted-foreground">
1163
+ {migration.name}
1164
+ </span>
1165
+ )}
1166
+ </div>
1167
+ </div>
1168
+ </td>
1169
+ <td className="px-6 py-4">
1170
+ <StatusBadge status={getStatusType(migration.status)}>
1171
+ {formatStatus(migration.status)}
1172
+ </StatusBadge>
1173
+ </td>
1174
+ <td className="px-6 py-4">
1175
+ <span className={`text-sm ${migration.appliedAt ? 'text-muted-foreground' : 'text-muted-foreground italic'}`}>
1176
+ {formatDate(migration.appliedAt)}
1177
+ </span>
1178
+ </td>
1179
+ <td className="px-6 py-4">
1180
+ <MigrationActions
1181
+ status={migration.status}
1182
+ onMigrate={() => handleMigrate(migration.id)}
1183
+ onRollback={() => handleRollbackClick(migration)}
1184
+ onRetry={() => handleRetry(migration.id)}
1185
+ onView={() => handleViewClick(migration)}
1186
+ isLoading={actionLoading === migration.id}
1187
+ />
1188
+ </td>
1189
+ </tr>
1190
+ ))
1191
+ )}
1192
+ </tbody>
1193
+ </table>
1194
+ </div>
1195
+ </div>
1196
+
1197
+ <Pagination
1198
+ currentPage={migrationsPage}
1199
+ totalPages={migrationsTotalPages}
1200
+ totalItems={filteredMigrations.length}
1201
+ perPage={migrationsPerPage}
1202
+ onPageChange={setMigrationsPage}
1203
+ onPerPageChange={(size) => { setMigrationsPerPage(size); setMigrationsPage(1); }}
1204
+ showPageSizeSelector
1205
+ itemsLabel="migrations"
1206
+ />
1207
+ </div>
1208
+ )}
1209
+
1210
+ {/* History Tab */}
1211
+ {activeTab === 'history' && (
1212
+ <div className="space-y-4">
1213
+ <div className="flex flex-col sm:flex-row gap-4 sm:items-center">
1214
+ <div className="flex-1 w-full min-w-0">
1215
+ <SearchInput
1216
+ placeholder="Search history..."
1217
+ value={historySearchQuery}
1218
+ onChange={(e) => setHistorySearchQuery(e.target.value)}
1219
+ icon={
1220
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1221
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
1222
+ </svg>
1223
+ }
1224
+ />
1225
+ </div>
1226
+ <div className="flex items-center gap-2 flex-shrink-0">
1227
+ <Button
1228
+ variant="outline"
1229
+ size="sm"
1230
+ onClick={() => {
1231
+ const headers = ['Action', 'Executed By', 'Timestamp', 'Duration', 'Status'];
1232
+ const rows = filteredHistory.map((h) => [
1233
+ h.action,
1234
+ h.executedBy || '',
1235
+ formatDate(h.executedAt),
1236
+ `${h.duration}ms`,
1237
+ h.errorMessage ? 'Failed' : 'Success',
1238
+ ]);
1239
+ const csv = [headers.join(','), ...rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(','))].join('\n');
1240
+ const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
1241
+ const link = document.createElement('a');
1242
+ link.href = URL.createObjectURL(blob);
1243
+ link.download = `migration-history-export-${new Date().toISOString().slice(0, 10)}.csv`;
1244
+ link.click();
1245
+ URL.revokeObjectURL(link.href);
1246
+ }}
1247
+ className="gap-2"
1248
+ >
1249
+ <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1250
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
1251
+ </svg>
1252
+ Export
1253
+ </Button>
1254
+ </div>
1255
+ </div>
1256
+ <div className="flex flex-wrap gap-2">
1257
+ <FilterChip active count={history.length} onClick={() => {}}>All History</FilterChip>
1258
+ </div>
1259
+ <div className="bg-card dark:bg-slate-800/50 rounded-2xl border border-border dark:border-slate-700 overflow-hidden">
1260
+ <div className="overflow-x-auto">
1261
+ <table className="w-full">
1262
+ <thead>
1263
+ <tr className="bg-muted/50 dark:bg-slate-800">
1264
+ <th className="text-left text-xs font-medium text-muted-foreground uppercase tracking-wider px-6 py-3">
1265
+ Action
1266
+ </th>
1267
+ <th className="text-left text-xs font-medium text-muted-foreground uppercase tracking-wider px-6 py-3">
1268
+ Executed By
1269
+ </th>
1270
+ <th className="text-left text-xs font-medium text-muted-foreground uppercase tracking-wider px-6 py-3">
1271
+ Timestamp
1272
+ </th>
1273
+ <th className="text-left text-xs font-medium text-muted-foreground uppercase tracking-wider px-6 py-3">
1274
+ Duration
1275
+ </th>
1276
+ <th className="text-left text-xs font-medium text-muted-foreground uppercase tracking-wider px-6 py-3">
1277
+ Status
1278
+ </th>
1279
+ </tr>
1280
+ </thead>
1281
+ <tbody className="divide-y divide-border dark:divide-slate-700">
1282
+ {history.length === 0 ? (
1283
+ <tr>
1284
+ <td colSpan={5} className="px-6 py-8 text-center text-muted-foreground">
1285
+ No history available
1286
+ </td>
1287
+ </tr>
1288
+ ) : (
1289
+ paginatedHistory.map((record) => (
1290
+ <tr key={record.id} className="hover:bg-muted/30 dark:hover:bg-slate-800/50 transition-colors">
1291
+ <td className="px-6 py-4">
1292
+ <span className="text-sm font-medium capitalize">{record.action}</span>
1293
+ </td>
1294
+ <td className="px-6 py-4">
1295
+ <span className="text-sm text-muted-foreground">{record.executedBy}</span>
1296
+ </td>
1297
+ <td className="px-6 py-4">
1298
+ <span className="text-sm text-muted-foreground">{formatDate(record.executedAt)}</span>
1299
+ </td>
1300
+ <td className="px-6 py-4">
1301
+ <span className="text-sm text-muted-foreground">{record.duration}ms</span>
1302
+ </td>
1303
+ <td className="px-6 py-4">
1304
+ {record.errorMessage ? (
1305
+ <StatusBadge status="error">Failed</StatusBadge>
1306
+ ) : (
1307
+ <StatusBadge status="success">Success</StatusBadge>
1308
+ )}
1309
+ </td>
1310
+ </tr>
1311
+ ))
1312
+ )}
1313
+ </tbody>
1314
+ </table>
1315
+ </div>
1316
+ </div>
1317
+ <Pagination
1318
+ currentPage={historyPage}
1319
+ totalPages={historyTotalPages}
1320
+ totalItems={filteredHistory.length}
1321
+ perPage={historyPerPage}
1322
+ onPageChange={setHistoryPage}
1323
+ onPerPageChange={(size) => { setHistoryPerPage(size); setHistoryPage(1); }}
1324
+ showPageSizeSelector
1325
+ itemsLabel="entries"
1326
+ />
1327
+ </div>
1328
+ )}
1329
+
1330
+ {/* Console Logs Tab */}
1331
+ {activeTab === 'logs' && (
1332
+ <div className="bg-card dark:bg-slate-800/50 rounded-2xl border border-border dark:border-slate-700 overflow-hidden">
1333
+ <ConsoleLog
1334
+ logs={logs}
1335
+ onClear={handleClearLogs}
1336
+ onCopy={handleCopyLogs}
1337
+ />
1338
+ </div>
1339
+ )}
1340
+
1341
+ {/* View Migration Dialog */}
1342
+ <ViewMigrationDialog
1343
+ migration={selectedMigration}
1344
+ isOpen={viewDialogOpen}
1345
+ onClose={() => {
1346
+ setViewDialogOpen(false);
1347
+ setSelectedMigration(null);
1348
+ }}
1349
+ />
1350
+
1351
+ {/* Rollback Confirmation Dialog */}
1352
+ <RollbackDialog
1353
+ migration={selectedMigration}
1354
+ isOpen={rollbackDialogOpen}
1355
+ onClose={() => {
1356
+ setRollbackDialogOpen(false);
1357
+ setSelectedMigration(null);
1358
+ }}
1359
+ onConfirm={handleRollbackConfirm}
1360
+ isLoading={rollbackLoading}
1361
+ />
1362
+ </div>
1363
+ );
1364
+ }