@alepha/ui 0.15.3 → 0.15.5

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 (99) hide show
  1. package/dist/admin/AdminApiKeys-DsmGnHNh.js +3 -0
  2. package/dist/admin/AdminApiKeys-GMORg-1l.js +442 -0
  3. package/dist/admin/AdminApiKeys-GMORg-1l.js.map +1 -0
  4. package/dist/admin/AdminAudits-8SM96viT.js +3 -0
  5. package/dist/admin/{AdminAudits-Oh7iAfQa.js → AdminAudits-pkWrjq1Z.js} +2 -2
  6. package/dist/admin/{AdminAudits-Oh7iAfQa.js.map → AdminAudits-pkWrjq1Z.js.map} +1 -1
  7. package/dist/admin/AdminFiles-B56ocq4H.js +3 -0
  8. package/dist/admin/{AdminFiles-Cu8GHgQ3.js → AdminFiles-WeQbsCsl.js} +2 -2
  9. package/dist/admin/{AdminFiles-Cu8GHgQ3.js.map → AdminFiles-WeQbsCsl.js.map} +1 -1
  10. package/dist/admin/AdminJobs-B-q9iGO3.js +697 -0
  11. package/dist/admin/AdminJobs-B-q9iGO3.js.map +1 -0
  12. package/dist/admin/AdminJobs-CED1syCn.js +3 -0
  13. package/dist/admin/AdminLayout-BX4FIpXv.css +143 -0
  14. package/dist/admin/AdminLayout-BX4FIpXv.css.map +1 -0
  15. package/dist/admin/{AdminLayout-QJLIesuG.js → AdminLayout-BqZiXx4H.js} +3 -2
  16. package/dist/admin/AdminLayout-BqZiXx4H.js.map +1 -0
  17. package/dist/admin/AdminNotifications-B0B1rdc4.js +3 -0
  18. package/dist/admin/{AdminNotifications-CgYkBuG_.js → AdminNotifications-Ds5Un0NJ.js} +2 -2
  19. package/dist/admin/{AdminNotifications-CgYkBuG_.js.map → AdminNotifications-Ds5Un0NJ.js.map} +1 -1
  20. package/dist/admin/{AdminParameters-Cl-R0nXt.js → AdminParameters-BU3lATdJ.js} +1 -1
  21. package/dist/admin/{AdminParameters-hjNG_KXb.js → AdminParameters-CfDUpc78.js} +4 -4
  22. package/dist/admin/{AdminParameters-hjNG_KXb.js.map → AdminParameters-CfDUpc78.js.map} +1 -1
  23. package/dist/admin/AdminSessions-BDGK2MS6.js +3 -0
  24. package/dist/admin/{AdminSessions-Bey9cuy1.js → AdminSessions-DzIOxM3b.js} +2 -2
  25. package/dist/admin/{AdminSessions-Bey9cuy1.js.map → AdminSessions-DzIOxM3b.js.map} +1 -1
  26. package/dist/admin/{AdminUserAudits-C7AN9jx7.js → AdminUserAudits-CiUPN2BC.js} +2 -2
  27. package/dist/admin/{AdminUserAudits-C7AN9jx7.js.map → AdminUserAudits-CiUPN2BC.js.map} +1 -1
  28. package/dist/admin/{AdminUserAudits-Cp_ERd2g.js → AdminUserAudits-Cj79gENT.js} +1 -1
  29. package/dist/admin/{AdminUserCreate-BVIm4JdN.js → AdminUserCreate-BwQKr4xE.js} +2 -2
  30. package/dist/admin/{AdminUserCreate-BVIm4JdN.js.map → AdminUserCreate-BwQKr4xE.js.map} +1 -1
  31. package/dist/admin/{AdminUserCreate-C1aInRDk.js → AdminUserCreate-Cq-mUmBs.js} +1 -1
  32. package/dist/admin/{AdminUserDetails-Dcn3OwMC.js → AdminUserDetails-DRjVAPFd.js} +1 -1
  33. package/dist/admin/{AdminUserDetails-yM4x8JE6.js → AdminUserDetails-uqtC5aJ1.js} +2 -2
  34. package/dist/admin/{AdminUserDetails-yM4x8JE6.js.map → AdminUserDetails-uqtC5aJ1.js.map} +1 -1
  35. package/dist/admin/{AdminUserLayout-gb-nbggz.js → AdminUserLayout-CGzmHHby.js} +1 -1
  36. package/dist/admin/{AdminUserLayout-BnfBC1gD.js → AdminUserLayout-CiPay35T.js} +2 -2
  37. package/dist/admin/{AdminUserLayout-BnfBC1gD.js.map → AdminUserLayout-CiPay35T.js.map} +1 -1
  38. package/dist/admin/{AdminUserSessions-kmkXG-xf.js → AdminUserSessions-DAE8Nf1F.js} +2 -2
  39. package/dist/admin/{AdminUserSessions-kmkXG-xf.js.map → AdminUserSessions-DAE8Nf1F.js.map} +1 -1
  40. package/dist/admin/AdminUserSessions-DcdzuNZ9.js +3 -0
  41. package/dist/admin/AdminUserSettings-D7V6-ceX.js +3 -0
  42. package/dist/admin/{AdminUserSettings-DZ9iWhJW.js → AdminUserSettings-EbahaV2a.js} +2 -2
  43. package/dist/admin/{AdminUserSettings-DZ9iWhJW.js.map → AdminUserSettings-EbahaV2a.js.map} +1 -1
  44. package/dist/admin/AdminUsers-D9nyzGqQ.js +3 -0
  45. package/dist/admin/{AdminUsers-D6Y5K8Am.js → AdminUsers-Dcjh0KNW.js} +2 -2
  46. package/dist/admin/{AdminUsers-D6Y5K8Am.js.map → AdminUsers-Dcjh0KNW.js.map} +1 -1
  47. package/dist/admin/index.d.ts +39 -51
  48. package/dist/admin/index.d.ts.map +1 -1
  49. package/dist/admin/index.js +52 -169
  50. package/dist/admin/index.js.map +1 -1
  51. package/dist/auth/AuthLayout-BaD7RD2h.css +143 -0
  52. package/dist/auth/AuthLayout-BaD7RD2h.css.map +1 -0
  53. package/dist/auth/AuthLayout-Dj5K4SIN.js.map +1 -1
  54. package/dist/auth/index.d.ts +9 -1
  55. package/dist/auth/index.d.ts.map +1 -1
  56. package/dist/auth/index.js +1 -2
  57. package/dist/auth/index.js.map +1 -1
  58. package/dist/core/index.d.ts +13 -21
  59. package/dist/core/index.d.ts.map +1 -1
  60. package/dist/core/index.js +26 -38
  61. package/dist/core/index.js.map +1 -1
  62. package/dist/demo/{DemoLogin-S-b15cmE.js → DemoLogin-CvCG2WVh.js} +3 -1
  63. package/dist/demo/{DemoLogin-S-b15cmE.js.map → DemoLogin-CvCG2WVh.js.map} +1 -1
  64. package/dist/demo/{DemoRegister-B29MdAaZ.js → DemoRegister-CmeHbOAs.js} +3 -1
  65. package/dist/demo/{DemoRegister-B29MdAaZ.js.map → DemoRegister-CmeHbOAs.js.map} +1 -1
  66. package/dist/demo/{DemoResetPassword-CPTy88iK.js → DemoResetPassword-CKO5iA_6.js} +3 -1
  67. package/dist/demo/{DemoResetPassword-CPTy88iK.js.map → DemoResetPassword-CKO5iA_6.js.map} +1 -1
  68. package/dist/demo/index.js +3 -3
  69. package/package.json +3 -3
  70. package/src/admin/AdminRouter.ts +34 -0
  71. package/src/admin/components/AdminLayout.tsx +2 -0
  72. package/src/admin/components/jobs/AdminJobs.tsx +733 -119
  73. package/src/admin/components/keys/AdminApiKeys.tsx +537 -0
  74. package/src/admin/components/parameters/AdminParameters.tsx +2 -3
  75. package/src/admin/index.ts +3 -5
  76. package/src/auth/AuthRouter.ts +1 -2
  77. package/src/auth/components/AuthLayout.tsx +1 -0
  78. package/src/core/components/buttons/ActionButton.tsx +15 -2
  79. package/src/core/components/buttons/DarkModeButton.css +6 -0
  80. package/src/core/components/buttons/DarkModeButton.tsx +18 -71
  81. package/src/core/components/buttons/LanguageButton.tsx +2 -7
  82. package/src/core/components/buttons/ThemeButton.tsx +2 -6
  83. package/src/core/components/layout/AdminShell.tsx +17 -1
  84. package/src/core/components/layout/AppBar.tsx +5 -8
  85. package/src/core/index.ts +0 -1
  86. package/src/core/styles.css +1 -0
  87. package/src/demo/components/auth/DemoLogin.tsx +2 -0
  88. package/src/demo/components/auth/DemoRegister.tsx +2 -0
  89. package/src/demo/components/auth/DemoResetPassword.tsx +2 -0
  90. package/dist/admin/AdminAudits-BU-p1g7A.js +0 -3
  91. package/dist/admin/AdminFiles-Bg9feLFH.js +0 -3
  92. package/dist/admin/AdminLayout-BfeFXiul.js +0 -3
  93. package/dist/admin/AdminLayout-QJLIesuG.js.map +0 -1
  94. package/dist/admin/AdminNotifications-DmfGPqHe.js +0 -3
  95. package/dist/admin/AdminSessions-Cn4_jB04.js +0 -3
  96. package/dist/admin/AdminUserSessions-rvA0ztxn.js +0 -3
  97. package/dist/admin/AdminUserSettings-Dg-wTRzN.js +0 -3
  98. package/dist/admin/AdminUsers-RCaxccEW.js +0 -3
  99. package/src/admin/MainRouter.ts +0 -23
@@ -1,10 +1,37 @@
1
- import { DataTable, Flex, Text } from "@alepha/ui";
2
- import { Badge } from "@mantine/core";
3
1
  import {
4
- IconCheck,
2
+ ActionButton,
3
+ DataTable,
4
+ Flex,
5
+ Text,
6
+ useDialog,
7
+ useToast,
8
+ } from "@alepha/ui";
9
+ import {
10
+ ActionIcon,
11
+ Badge,
12
+ Box,
13
+ Card,
14
+ Group,
15
+ Paper,
16
+ Progress,
17
+ RingProgress,
18
+ ScrollArea,
19
+ SimpleGrid,
20
+ Skeleton,
21
+ Stack,
22
+ Tabs,
23
+ ThemeIcon,
24
+ Tooltip,
25
+ useMantineTheme,
26
+ } from "@mantine/core";
27
+ import {
28
+ IconAlertTriangle,
29
+ IconCircleCheck,
30
+ IconCircleX,
5
31
  IconClock,
6
32
  IconPlayerPlay,
7
- IconX,
33
+ IconRefresh,
34
+ IconTerminal2,
8
35
  } from "@tabler/icons-react";
9
36
  import { type Page, t } from "alepha";
10
37
  import {
@@ -14,10 +41,418 @@ import {
14
41
  } from "alepha/api/jobs";
15
42
  import { useClient } from "alepha/react";
16
43
  import { useI18n } from "alepha/react/i18n";
44
+ import { useCallback, useEffect, useState } from "react";
45
+
46
+ // ─────────────────────────────────────────────────────────────────────────────
47
+ // Types
48
+ // ─────────────────────────────────────────────────────────────────────────────
49
+
50
+ interface JobStats {
51
+ name: string;
52
+ total: number;
53
+ completed: number;
54
+ failed: number;
55
+ running: number;
56
+ avgDuration: number;
57
+ lastRun?: Date;
58
+ lastStatus?: "COMPLETED" | "FAILED" | "STARTED";
59
+ }
60
+
61
+ interface LogEntry {
62
+ level: string;
63
+ message: string;
64
+ service: string;
65
+ module: string;
66
+ timestamp: number;
67
+ data?: unknown;
68
+ }
69
+
70
+ // ─────────────────────────────────────────────────────────────────────────────
71
+ // Utilities
72
+ // ─────────────────────────────────────────────────────────────────────────────
73
+
74
+ const formatDuration = (
75
+ start: Date | string,
76
+ end?: Date | string | null,
77
+ ): string => {
78
+ const startTime = new Date(start).getTime();
79
+ const endTime = end ? new Date(end).getTime() : Date.now();
80
+ const duration = endTime - startTime;
81
+
82
+ if (duration < 1000) return `${duration}ms`;
83
+ if (duration < 60000) return `${(duration / 1000).toFixed(1)}s`;
84
+ if (duration < 3600000)
85
+ return `${Math.floor(duration / 60000)}m ${Math.floor((duration % 60000) / 1000)}s`;
86
+ return `${Math.floor(duration / 3600000)}h ${Math.floor((duration % 3600000) / 60000)}m`;
87
+ };
88
+
89
+ const getStatusColor = (status: string) => {
90
+ switch (status) {
91
+ case "COMPLETED":
92
+ return "teal";
93
+ case "FAILED":
94
+ return "red";
95
+ case "STARTED":
96
+ return "blue";
97
+ default:
98
+ return "gray";
99
+ }
100
+ };
101
+
102
+ const getStatusIcon = (status: string, size = 14) => {
103
+ switch (status) {
104
+ case "COMPLETED":
105
+ return <IconCircleCheck size={size} />;
106
+ case "FAILED":
107
+ return <IconCircleX size={size} />;
108
+ case "STARTED":
109
+ return <IconPlayerPlay size={size} />;
110
+ default:
111
+ return <IconClock size={size} />;
112
+ }
113
+ };
114
+
115
+ const getLogLevelColor = (level: string) => {
116
+ switch (level) {
117
+ case "ERROR":
118
+ return "red";
119
+ case "WARN":
120
+ return "yellow";
121
+ case "INFO":
122
+ return "blue";
123
+ case "DEBUG":
124
+ return "gray";
125
+ case "TRACE":
126
+ return "dimmed";
127
+ default:
128
+ return "dimmed";
129
+ }
130
+ };
131
+
132
+ // ─────────────────────────────────────────────────────────────────────────────
133
+ // Job Card Component
134
+ // ─────────────────────────────────────────────────────────────────────────────
135
+
136
+ interface JobCardProps {
137
+ job: string;
138
+ stats?: JobStats;
139
+ isTriggering: boolean;
140
+ onTrigger: (job: string) => void;
141
+ onSelect: (job: string) => void;
142
+ isSelected: boolean;
143
+ }
144
+
145
+ const JobCard = (props: JobCardProps) => {
146
+ const { job, stats, isTriggering, onTrigger, onSelect, isSelected } = props;
147
+ const theme = useMantineTheme();
148
+
149
+ const successRate = stats
150
+ ? stats.total > 0
151
+ ? (stats.completed / stats.total) * 100
152
+ : 0
153
+ : 0;
154
+
155
+ return (
156
+ <Card
157
+ p="md"
158
+ radius="md"
159
+ withBorder
160
+ onClick={() => onSelect(job)}
161
+ style={{
162
+ cursor: "pointer",
163
+ borderColor: isSelected ? theme.colors.blue[6] : undefined,
164
+ backgroundColor: isSelected
165
+ ? "var(--mantine-color-blue-light)"
166
+ : undefined,
167
+ transition: "all 150ms ease",
168
+ }}
169
+ >
170
+ <Group justify="space-between" mb="xs">
171
+ <Group gap="xs">
172
+ <ThemeIcon
173
+ size="sm"
174
+ radius="sm"
175
+ variant="light"
176
+ color={
177
+ stats?.lastStatus ? getStatusColor(stats.lastStatus) : "gray"
178
+ }
179
+ >
180
+ <IconTerminal2 size={14} />
181
+ </ThemeIcon>
182
+ <Text size="sm" fw={600} ff="monospace">
183
+ {job}
184
+ </Text>
185
+ </Group>
186
+ <Tooltip label="Trigger job manually" position="left">
187
+ <ActionIcon
188
+ size="sm"
189
+ variant="light"
190
+ color="blue"
191
+ loading={isTriggering}
192
+ onClick={(e) => {
193
+ e.stopPropagation();
194
+ onTrigger(job);
195
+ }}
196
+ >
197
+ <IconPlayerPlay size={12} />
198
+ </ActionIcon>
199
+ </Tooltip>
200
+ </Group>
201
+
202
+ {stats ? (
203
+ <>
204
+ <Group gap="lg" mb="xs">
205
+ <Box>
206
+ <Text size="xs" c="dimmed" tt="uppercase" fw={500}>
207
+ Total
208
+ </Text>
209
+ <Text size="lg" fw={700} ff="monospace">
210
+ {stats.total}
211
+ </Text>
212
+ </Box>
213
+ <Box>
214
+ <Text size="xs" c="dimmed" tt="uppercase" fw={500}>
215
+ Success
216
+ </Text>
217
+ <Text size="lg" fw={700} ff="monospace" c="teal">
218
+ {stats.completed}
219
+ </Text>
220
+ </Box>
221
+ <Box>
222
+ <Text size="xs" c="dimmed" tt="uppercase" fw={500}>
223
+ Failed
224
+ </Text>
225
+ <Text size="lg" fw={700} ff="monospace" c="red">
226
+ {stats.failed}
227
+ </Text>
228
+ </Box>
229
+ </Group>
230
+
231
+ <Progress.Root size="sm" radius="xs">
232
+ <Tooltip label={`${stats.completed} completed`}>
233
+ <Progress.Section
234
+ value={(stats.completed / Math.max(stats.total, 1)) * 100}
235
+ color="teal"
236
+ />
237
+ </Tooltip>
238
+ <Tooltip label={`${stats.failed} failed`}>
239
+ <Progress.Section
240
+ value={(stats.failed / Math.max(stats.total, 1)) * 100}
241
+ color="red"
242
+ />
243
+ </Tooltip>
244
+ <Tooltip label={`${stats.running} running`}>
245
+ <Progress.Section
246
+ value={(stats.running / Math.max(stats.total, 1)) * 100}
247
+ color="blue"
248
+ />
249
+ </Tooltip>
250
+ </Progress.Root>
251
+
252
+ {stats.lastRun && (
253
+ <Text size="xs" c="dimmed" mt="xs">
254
+ Last run: {formatDuration(stats.lastRun, new Date())} ago
255
+ </Text>
256
+ )}
257
+ </>
258
+ ) : (
259
+ <Stack gap="xs">
260
+ <Skeleton height={8} radius="xl" />
261
+ <Skeleton height={8} width="70%" radius="xl" />
262
+ </Stack>
263
+ )}
264
+ </Card>
265
+ );
266
+ };
267
+
268
+ // ─────────────────────────────────────────────────────────────────────────────
269
+ // Execution Log Viewer
270
+ // ─────────────────────────────────────────────────────────────────────────────
271
+
272
+ interface ExecutionLogViewerProps {
273
+ logs?: LogEntry[];
274
+ error?: string | null;
275
+ }
276
+
277
+ const ExecutionLogViewer = (props: ExecutionLogViewerProps) => {
278
+ const { logs, error } = props;
279
+
280
+ if (!logs?.length && !error) {
281
+ return (
282
+ <Box p="md">
283
+ <Text size="sm" c="dimmed" ta="center">
284
+ No logs available
285
+ </Text>
286
+ </Box>
287
+ );
288
+ }
289
+
290
+ return (
291
+ <ScrollArea h={300} type="auto">
292
+ <Box
293
+ p="md"
294
+ style={{
295
+ fontFamily: "var(--mantine-font-family-monospace)",
296
+ fontSize: "12px",
297
+ lineHeight: 1.6,
298
+ }}
299
+ >
300
+ {error && (
301
+ <Paper p="sm" mb="md" bg="var(--mantine-color-red-light)" radius="sm">
302
+ <Group gap="xs" align="flex-start">
303
+ <IconAlertTriangle
304
+ size={14}
305
+ color="var(--mantine-color-red-filled)"
306
+ />
307
+ <Text
308
+ size="xs"
309
+ c="red"
310
+ style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}
311
+ >
312
+ {error}
313
+ </Text>
314
+ </Group>
315
+ </Paper>
316
+ )}
317
+
318
+ {logs?.map((log, index) => (
319
+ <Group key={index} gap="sm" align="flex-start" mb={4} wrap="nowrap">
320
+ <Text size="xs" c="dimmed" style={{ minWidth: 80, flexShrink: 0 }}>
321
+ {new Date(log.timestamp).toLocaleTimeString()}
322
+ </Text>
323
+ <Badge
324
+ size="xs"
325
+ variant="light"
326
+ color={getLogLevelColor(log.level)}
327
+ style={{ minWidth: 50 }}
328
+ >
329
+ {log.level}
330
+ </Badge>
331
+ <Text size="xs" c="dimmed" style={{ minWidth: 100, flexShrink: 0 }}>
332
+ {log.module}
333
+ </Text>
334
+ <Text size="xs" style={{ wordBreak: "break-word" }}>
335
+ {log.message}
336
+ </Text>
337
+ </Group>
338
+ ))}
339
+ </Box>
340
+ </ScrollArea>
341
+ );
342
+ };
343
+
344
+ // ─────────────────────────────────────────────────────────────────────────────
345
+ // Main Component
346
+ // ─────────────────────────────────────────────────────────────────────────────
17
347
 
18
348
  const AdminJobs = () => {
19
349
  const client = useClient<AdminJobController>();
20
350
  const { l } = useI18n();
351
+ const toast = useToast();
352
+ const dialog = useDialog();
353
+
354
+ const [jobs, setJobs] = useState<string[]>([]);
355
+ const [jobStats, setJobStats] = useState<Map<string, JobStats>>(new Map());
356
+ const [selectedJob, setSelectedJob] = useState<string | null>(null);
357
+ const [triggeringJobs, setTriggeringJobs] = useState<Set<string>>(new Set());
358
+ const [refreshKey, setRefreshKey] = useState(0);
359
+ const [loading, setLoading] = useState(true);
360
+ const [activeTab, setActiveTab] = useState<string | null>("overview");
361
+
362
+ // Load jobs list
363
+ useEffect(() => {
364
+ const loadJobs = async () => {
365
+ try {
366
+ const jobList = await client.getJobs();
367
+ setJobs(jobList);
368
+
369
+ // Load stats for each job
370
+ const statsMap = new Map<string, JobStats>();
371
+ for (const job of jobList) {
372
+ const executions = await client.getJobExecutions({
373
+ query: { job, size: 100 },
374
+ });
375
+
376
+ const items = executions.content || [];
377
+ const completed = items.filter(
378
+ (e: JobExecutionEntity) => e.status === "COMPLETED",
379
+ ).length;
380
+ const failed = items.filter(
381
+ (e: JobExecutionEntity) => e.status === "FAILED",
382
+ ).length;
383
+ const running = items.filter(
384
+ (e: JobExecutionEntity) => e.status === "STARTED",
385
+ ).length;
386
+
387
+ const completedItems = items.filter(
388
+ (e: JobExecutionEntity) => e.status === "COMPLETED" && e.finishedAt,
389
+ );
390
+ const avgDuration =
391
+ completedItems.length > 0
392
+ ? completedItems.reduce((acc: number, e: JobExecutionEntity) => {
393
+ const duration =
394
+ new Date(e.finishedAt!).getTime() -
395
+ new Date(e.createdAt).getTime();
396
+ return acc + duration;
397
+ }, 0) / completedItems.length
398
+ : 0;
399
+
400
+ const lastItem = items[0];
401
+
402
+ statsMap.set(job, {
403
+ name: job,
404
+ total: items.length,
405
+ completed,
406
+ failed,
407
+ running,
408
+ avgDuration,
409
+ lastRun: lastItem?.createdAt
410
+ ? new Date(lastItem.createdAt)
411
+ : undefined,
412
+ lastStatus: lastItem?.status as JobStats["lastStatus"],
413
+ });
414
+ }
415
+
416
+ setJobStats(statsMap);
417
+ } catch (error) {
418
+ toast.danger("Failed to load jobs");
419
+ } finally {
420
+ setLoading(false);
421
+ }
422
+ };
423
+
424
+ loadJobs();
425
+ }, [refreshKey]);
426
+
427
+ const handleTriggerJob = useCallback(
428
+ async (job: string) => {
429
+ const confirmed = await dialog.confirm({
430
+ title: "Trigger Job",
431
+ message: `Are you sure you want to trigger "${job}" manually?`,
432
+ confirmLabel: "Trigger",
433
+ confirmColor: "blue",
434
+ });
435
+
436
+ if (!confirmed) return;
437
+
438
+ setTriggeringJobs((prev) => new Set(prev).add(job));
439
+
440
+ try {
441
+ await client.triggerJob({ body: { name: job } });
442
+ toast.success(`Job "${job}" triggered successfully`);
443
+ setRefreshKey((k) => k + 1);
444
+ } catch (error) {
445
+ toast.danger(`Failed to trigger job "${job}"`);
446
+ } finally {
447
+ setTriggeringJobs((prev) => {
448
+ const next = new Set(prev);
449
+ next.delete(job);
450
+ return next;
451
+ });
452
+ }
453
+ },
454
+ [client, dialog, toast],
455
+ );
21
456
 
22
457
  const filters = t.object({
23
458
  job: t.optional(
@@ -30,127 +465,306 @@ const AdminJobs = () => {
30
465
  status: t.optional(t.enum(["STARTED", "FAILED", "COMPLETED"])),
31
466
  });
32
467
 
33
- const getStatusColor = (status: string) => {
34
- switch (status) {
35
- case "COMPLETED":
36
- return "green";
37
- case "FAILED":
38
- return "red";
39
- case "STARTED":
40
- return "blue";
41
- default:
42
- return "gray";
43
- }
44
- };
45
-
46
- const getStatusIcon = (status: string) => {
47
- switch (status) {
48
- case "COMPLETED":
49
- return <IconCheck size={12} />;
50
- case "FAILED":
51
- return <IconX size={12} />;
52
- case "STARTED":
53
- return <IconPlayerPlay size={12} />;
54
- default:
55
- return <IconClock size={12} />;
56
- }
468
+ // Calculate global stats
469
+ const globalStats = {
470
+ total: Array.from(jobStats.values()).reduce((acc, s) => acc + s.total, 0),
471
+ completed: Array.from(jobStats.values()).reduce(
472
+ (acc, s) => acc + s.completed,
473
+ 0,
474
+ ),
475
+ failed: Array.from(jobStats.values()).reduce((acc, s) => acc + s.failed, 0),
476
+ running: Array.from(jobStats.values()).reduce(
477
+ (acc, s) => acc + s.running,
478
+ 0,
479
+ ),
57
480
  };
58
481
 
59
- const formatDuration = (
60
- start: Date | string,
61
- end?: Date | string | null,
62
- ): string => {
63
- const startTime = new Date(start).getTime();
64
- const endTime = end ? new Date(end).getTime() : Date.now();
65
- const duration = endTime - startTime;
66
-
67
- if (duration < 1000) return `${duration}ms`;
68
- if (duration < 60000) return `${(duration / 1000).toFixed(1)}s`;
69
- return `${Math.floor(duration / 60000)}m ${Math.floor((duration % 60000) / 1000)}s`;
70
- };
482
+ const successRate =
483
+ globalStats.total > 0
484
+ ? Math.round((globalStats.completed / globalStats.total) * 100)
485
+ : 0;
71
486
 
72
487
  return (
73
- <Flex flex={1} direction={"column"}>
74
- <DataTable<JobExecutionEntity, typeof filters>
75
- submitOnInit
76
- defaultSize={10}
77
- typeFormProps={{
78
- skipSubmitButton: true,
79
- columns: 3,
80
- }}
81
- tableProps={{
82
- horizontalSpacing: "xs",
83
- verticalSpacing: "xs",
84
- }}
85
- onFilterChange={(key, _value, form) => {
86
- if (key === "job" || key === "status") {
87
- return form.submit();
88
- }
89
- }}
90
- filters={filters}
91
- items={async (filters) => {
92
- const response = await client.getJobExecutions({
93
- query: filters,
94
- });
488
+ <Flex flex={1} direction="column" gap="md">
489
+ {/* Header Stats */}
490
+ <SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
491
+ <Paper p="md" radius="md" withBorder>
492
+ <Group justify="space-between">
493
+ <Box>
494
+ <Text size="xs" c="dimmed" tt="uppercase" fw={600}>
495
+ Total Jobs
496
+ </Text>
497
+ <Text size="xl" fw={700} ff="monospace">
498
+ {jobs.length}
499
+ </Text>
500
+ </Box>
501
+ <ThemeIcon size="lg" radius="md" variant="light" color="blue">
502
+ <IconTerminal2 size={20} />
503
+ </ThemeIcon>
504
+ </Group>
505
+ </Paper>
95
506
 
96
- return response as Page<JobExecutionEntity>;
97
- }}
98
- columns={{
99
- job: {
100
- label: "Job",
101
- value: (item) => (
102
- <Text size="sm" fw={500}>
103
- {item.job}
104
- </Text>
105
- ),
106
- },
107
- status: {
108
- label: "Status",
109
- fit: true,
110
- value: (item) => (
111
- <Badge
112
- size="sm"
113
- variant="light"
114
- color={getStatusColor(item.status)}
115
- leftSection={getStatusIcon(item.status)}
507
+ <Paper p="md" radius="md" withBorder>
508
+ <Group justify="space-between">
509
+ <Box>
510
+ <Text size="xs" c="dimmed" tt="uppercase" fw={600}>
511
+ Executions
512
+ </Text>
513
+ <Text size="xl" fw={700} ff="monospace">
514
+ {globalStats.total}
515
+ </Text>
516
+ </Box>
517
+ <ThemeIcon size="lg" radius="md" variant="light" color="gray">
518
+ <IconClock size={20} />
519
+ </ThemeIcon>
520
+ </Group>
521
+ </Paper>
522
+
523
+ <Paper p="md" radius="md" withBorder>
524
+ <Group justify="space-between">
525
+ <Box>
526
+ <Text size="xs" c="dimmed" tt="uppercase" fw={600}>
527
+ Success Rate
528
+ </Text>
529
+ <Text
530
+ size="xl"
531
+ fw={700}
532
+ ff="monospace"
533
+ c={
534
+ successRate >= 90
535
+ ? "teal"
536
+ : successRate >= 70
537
+ ? "yellow"
538
+ : "red"
539
+ }
116
540
  >
117
- {item.status}
118
- </Badge>
119
- ),
120
- },
121
- duration: {
122
- label: "Duration",
123
- fit: true,
124
- value: (item) => (
125
- <Text size="xs" c="dimmed" ff="monospace">
126
- {formatDuration(item.createdAt, item.finishedAt)}
127
- </Text>
128
- ),
129
- },
130
- error: {
131
- label: "Error",
132
- value: (item) =>
133
- item.error ? (
134
- <Text size="xs" c="red" lineClamp={1}>
135
- {item.error}
136
- </Text>
137
- ) : (
138
- <Text size="xs" c="dimmed">
139
- -
140
- </Text>
141
- ),
142
- },
143
- createdAt: {
144
- label: "Started",
145
- fit: true,
146
- value: (item) => (
147
- <Text size="xs" c="dimmed">
148
- {l(item.createdAt, { date: "fromNow" })}
149
- </Text>
150
- ),
151
- },
152
- }}
153
- />
541
+ {successRate}%
542
+ </Text>
543
+ </Box>
544
+ <RingProgress
545
+ size={48}
546
+ thickness={4}
547
+ roundCaps
548
+ sections={[
549
+ {
550
+ value: successRate,
551
+ color:
552
+ successRate >= 90
553
+ ? "teal"
554
+ : successRate >= 70
555
+ ? "yellow"
556
+ : "red",
557
+ },
558
+ ]}
559
+ />
560
+ </Group>
561
+ </Paper>
562
+
563
+ <Paper p="md" radius="md" withBorder>
564
+ <Group justify="space-between">
565
+ <Box>
566
+ <Text size="xs" c="dimmed" tt="uppercase" fw={600}>
567
+ Running Now
568
+ </Text>
569
+ <Text size="xl" fw={700} ff="monospace" c="blue">
570
+ {globalStats.running}
571
+ </Text>
572
+ </Box>
573
+ <ThemeIcon
574
+ size="lg"
575
+ radius="md"
576
+ variant="light"
577
+ color={globalStats.running > 0 ? "blue" : "gray"}
578
+ >
579
+ <IconPlayerPlay size={20} />
580
+ </ThemeIcon>
581
+ </Group>
582
+ </Paper>
583
+ </SimpleGrid>
584
+
585
+ {/* Tabs */}
586
+ <Tabs value={activeTab} onChange={setActiveTab}>
587
+ <Tabs.List>
588
+ <Tabs.Tab value="overview" leftSection={<IconTerminal2 size={14} />}>
589
+ Jobs Overview
590
+ </Tabs.Tab>
591
+ <Tabs.Tab value="executions" leftSection={<IconClock size={14} />}>
592
+ Execution History
593
+ </Tabs.Tab>
594
+ </Tabs.List>
595
+
596
+ <Tabs.Panel value="overview" pt="md">
597
+ <Group justify="space-between" mb="md">
598
+ <Text size="sm" c="dimmed">
599
+ {jobs.length} registered job{jobs.length !== 1 ? "s" : ""}
600
+ </Text>
601
+ <ActionButton
602
+ size="xs"
603
+ variant="light"
604
+ leftSection={<IconRefresh size={14} />}
605
+ onClick={() => setRefreshKey((k) => k + 1)}
606
+ >
607
+ Refresh
608
+ </ActionButton>
609
+ </Group>
610
+
611
+ {loading ? (
612
+ <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
613
+ {[1, 2, 3].map((i) => (
614
+ <Skeleton key={i} height={150} radius="md" />
615
+ ))}
616
+ </SimpleGrid>
617
+ ) : jobs.length === 0 ? (
618
+ <Paper p="xl" radius="md" withBorder ta="center">
619
+ <IconTerminal2 size={48} color="var(--mantine-color-dimmed)" />
620
+ <Text size="lg" fw={500} mt="md">
621
+ No jobs registered
622
+ </Text>
623
+ <Text size="sm" c="dimmed" mt="xs">
624
+ Jobs will appear here once they are defined using $job primitive
625
+ </Text>
626
+ </Paper>
627
+ ) : (
628
+ <SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
629
+ {jobs.map((job) => (
630
+ <JobCard
631
+ key={job}
632
+ job={job}
633
+ stats={jobStats.get(job)}
634
+ isTriggering={triggeringJobs.has(job)}
635
+ onTrigger={handleTriggerJob}
636
+ onSelect={setSelectedJob}
637
+ isSelected={selectedJob === job}
638
+ />
639
+ ))}
640
+ </SimpleGrid>
641
+ )}
642
+ </Tabs.Panel>
643
+
644
+ <Tabs.Panel value="executions" pt="md">
645
+ <DataTable<JobExecutionEntity, typeof filters>
646
+ key={refreshKey}
647
+ submitOnInit
648
+ defaultSize={15}
649
+ typeFormProps={{
650
+ skipSubmitButton: true,
651
+ columns: 3,
652
+ }}
653
+ tableProps={{
654
+ horizontalSpacing: "sm",
655
+ verticalSpacing: "sm",
656
+ highlightOnHover: true,
657
+ }}
658
+ onFilterChange={(key, _value, form) => {
659
+ if (key === "job" || key === "status") {
660
+ return form.submit();
661
+ }
662
+ }}
663
+ filters={filters}
664
+ items={async (filters) => {
665
+ const response = await client.getJobExecutions({
666
+ query: {
667
+ ...filters,
668
+ job: selectedJob || filters.job,
669
+ },
670
+ });
671
+
672
+ return response as Page<JobExecutionEntity>;
673
+ }}
674
+ columns={{
675
+ job: {
676
+ label: "Job",
677
+ value: (item) => (
678
+ <Text size="sm" fw={500} ff="monospace">
679
+ {item.job}
680
+ </Text>
681
+ ),
682
+ },
683
+ status: {
684
+ label: "Status",
685
+ fit: true,
686
+ value: (item) => (
687
+ <Badge
688
+ size="sm"
689
+ variant="light"
690
+ color={getStatusColor(item.status)}
691
+ leftSection={getStatusIcon(item.status, 12)}
692
+ >
693
+ {item.status}
694
+ </Badge>
695
+ ),
696
+ },
697
+ duration: {
698
+ label: "Duration",
699
+ fit: true,
700
+ value: (item) => (
701
+ <Text size="xs" c="dimmed" ff="monospace">
702
+ {formatDuration(item.createdAt, item.finishedAt)}
703
+ </Text>
704
+ ),
705
+ },
706
+ logs: {
707
+ label: "Logs",
708
+ fit: true,
709
+ value: (item) => {
710
+ const logCount =
711
+ (item.logs as LogEntry[] | undefined)?.length || 0;
712
+ const errorCount =
713
+ (item.logs as LogEntry[] | undefined)?.filter(
714
+ (log) => log.level === "ERROR",
715
+ ).length || 0;
716
+
717
+ return (
718
+ <Group gap={4}>
719
+ <Badge size="xs" variant="light" color="gray">
720
+ {logCount} logs
721
+ </Badge>
722
+ {errorCount > 0 && (
723
+ <Badge size="xs" variant="light" color="red">
724
+ {errorCount} errors
725
+ </Badge>
726
+ )}
727
+ </Group>
728
+ );
729
+ },
730
+ },
731
+ error: {
732
+ label: "Error",
733
+ value: (item) =>
734
+ item.error ? (
735
+ <Tooltip label={item.error} multiline w={300}>
736
+ <Text size="xs" c="red" lineClamp={1}>
737
+ {item.error}
738
+ </Text>
739
+ </Tooltip>
740
+ ) : (
741
+ <Text size="xs" c="dimmed">
742
+
743
+ </Text>
744
+ ),
745
+ },
746
+ createdAt: {
747
+ label: "Started",
748
+ fit: true,
749
+ value: (item) => (
750
+ <Text size="xs" c="dimmed">
751
+ {l(item.createdAt, { date: "fromNow" })}
752
+ </Text>
753
+ ),
754
+ },
755
+ }}
756
+ panel={(item) => (
757
+ <Box bg="var(--mantine-color-dark-7)" p={0}>
758
+ <ExecutionLogViewer
759
+ logs={item.logs as LogEntry[] | undefined}
760
+ error={item.error}
761
+ />
762
+ </Box>
763
+ )}
764
+ canPanel={(item) => Boolean(item.logs?.length || item.error)}
765
+ />
766
+ </Tabs.Panel>
767
+ </Tabs>
154
768
  </Flex>
155
769
  );
156
770
  };