@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.
- package/dist/emulator/lego-box-shell-1.0.6.tgz +0 -0
- package/package.json +2 -3
- package/src/auth/auth-store.ts +33 -0
- package/src/auth/auth.ts +176 -0
- package/src/components/ProtectedPage.tsx +48 -0
- package/src/config/env.node.ts +38 -0
- package/src/config/env.ts +103 -0
- package/src/context/AbilityContext.tsx +213 -0
- package/src/context/PiralInstanceContext.tsx +17 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/useAuditLogs.ts +190 -0
- package/src/hooks/useDebounce.ts +34 -0
- package/src/hooks/usePermissionGuard.tsx +39 -0
- package/src/hooks/usePermissions.ts +190 -0
- package/src/hooks/useRoles.ts +233 -0
- package/src/hooks/useTickets.ts +214 -0
- package/src/hooks/useUserLogins.ts +39 -0
- package/src/hooks/useUsers.ts +252 -0
- package/src/index.html +16 -0
- package/src/index.tsx +296 -0
- package/src/layout.tsx +246 -0
- package/src/migrations/config.ts +62 -0
- package/src/migrations/dev-migrations.ts +75 -0
- package/src/migrations/index.ts +13 -0
- package/src/migrations/run-migrations.ts +187 -0
- package/src/migrations/runner.ts +925 -0
- package/src/migrations/types.ts +207 -0
- package/src/migrations/utils.ts +264 -0
- package/src/pages/AuditLogsPage.tsx +378 -0
- package/src/pages/ContactSupportPage.tsx +610 -0
- package/src/pages/LandingPage.tsx +221 -0
- package/src/pages/LoginPage.tsx +217 -0
- package/src/pages/MigrationsPage.tsx +1364 -0
- package/src/pages/ProfilePage.tsx +335 -0
- package/src/pages/SettingsPage.tsx +101 -0
- package/src/pages/SystemHealthCheckPage.tsx +144 -0
- package/src/pages/UserManagementPage.tsx +1010 -0
- package/src/piral/api.ts +39 -0
- package/src/piral/auth-casl.ts +56 -0
- package/src/piral/menu.ts +102 -0
- package/src/piral/piral.json +4 -0
- package/src/services/telemetry.ts +37 -0
- package/src/styles/globals.css +1351 -0
- package/src/utils/auditLogger.ts +68 -0
- 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
|
+
}
|