@lego-box/shell 1.0.5 → 1.0.7

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 (49) hide show
  1. package/.krasrc +13 -0
  2. package/dist/emulator/lego-box-shell-1.0.7.tgz +0 -0
  3. package/package.json +6 -3
  4. package/postcss.config.js +6 -0
  5. package/src/auth/auth-store.ts +33 -0
  6. package/src/auth/auth.ts +176 -0
  7. package/src/components/ProtectedPage.tsx +48 -0
  8. package/src/config/env.node.ts +38 -0
  9. package/src/config/env.ts +105 -0
  10. package/src/context/AbilityContext.tsx +213 -0
  11. package/src/context/PiralInstanceContext.tsx +17 -0
  12. package/src/hooks/index.ts +11 -0
  13. package/src/hooks/useAuditLogs.ts +190 -0
  14. package/src/hooks/useDebounce.ts +34 -0
  15. package/src/hooks/usePermissionGuard.tsx +39 -0
  16. package/src/hooks/usePermissions.ts +190 -0
  17. package/src/hooks/useRoles.ts +233 -0
  18. package/src/hooks/useTickets.ts +214 -0
  19. package/src/hooks/useUserLogins.ts +39 -0
  20. package/src/hooks/useUsers.ts +252 -0
  21. package/src/index.html +16 -0
  22. package/src/index.tsx +296 -0
  23. package/src/layout.tsx +246 -0
  24. package/src/migrations/config.ts +62 -0
  25. package/src/migrations/dev-migrations.ts +75 -0
  26. package/src/migrations/index.ts +13 -0
  27. package/src/migrations/run-migrations.ts +187 -0
  28. package/src/migrations/runner.ts +925 -0
  29. package/src/migrations/types.ts +207 -0
  30. package/src/migrations/utils.ts +264 -0
  31. package/src/pages/AuditLogsPage.tsx +378 -0
  32. package/src/pages/ContactSupportPage.tsx +610 -0
  33. package/src/pages/LandingPage.tsx +221 -0
  34. package/src/pages/LoginPage.tsx +217 -0
  35. package/src/pages/MigrationsPage.tsx +1364 -0
  36. package/src/pages/ProfilePage.tsx +335 -0
  37. package/src/pages/SettingsPage.tsx +101 -0
  38. package/src/pages/SystemHealthCheckPage.tsx +144 -0
  39. package/src/pages/UserManagementPage.tsx +1010 -0
  40. package/src/piral/api.ts +39 -0
  41. package/src/piral/auth-casl.ts +56 -0
  42. package/src/piral/menu.ts +102 -0
  43. package/src/piral/piral.json +4 -0
  44. package/src/services/telemetry.ts +84 -0
  45. package/src/styles/globals.css +1351 -0
  46. package/src/utils/auditLogger.ts +68 -0
  47. package/tailwind.config.js +86 -0
  48. package/webpack.config.js +89 -0
  49. package/dist/emulator/lego-box-shell-1.0.5.tgz +0 -0
@@ -0,0 +1,335 @@
1
+ import * as React from 'react';
2
+ import { useGlobalState } from 'piral';
3
+ import {
4
+ Card,
5
+ CardHeader,
6
+ CardTitle,
7
+ CardDescription,
8
+ CardContent,
9
+ Badge,
10
+ Loading,
11
+ } from '@lego-box/ui-kit';
12
+ import { usePermissions } from '../hooks/usePermissions';
13
+ import { useCan } from '../context/AbilityContext';
14
+ import { usePiralInstance } from '../context/PiralInstanceContext';
15
+ import { Check, X, Shield, Mail, Calendar, Clock, Hash, User } from 'lucide-react';
16
+
17
+ interface ProfileUser {
18
+ id: string;
19
+ name: string;
20
+ email: string;
21
+ avatar?: string;
22
+ roleName?: string;
23
+ is_superuser?: boolean;
24
+ isActive?: boolean;
25
+ created?: string;
26
+ updated?: string;
27
+ verified?: boolean;
28
+ }
29
+
30
+ function formatDate(timestamp: string): string {
31
+ try {
32
+ return new Date(timestamp).toLocaleDateString(undefined, {
33
+ year: 'numeric',
34
+ month: 'long',
35
+ day: 'numeric',
36
+ });
37
+ } catch {
38
+ return timestamp;
39
+ }
40
+ }
41
+
42
+ function formatDateTime(timestamp: string): string {
43
+ try {
44
+ return new Date(timestamp).toLocaleString(undefined, {
45
+ year: 'numeric',
46
+ month: 'short',
47
+ day: 'numeric',
48
+ hour: '2-digit',
49
+ minute: '2-digit',
50
+ });
51
+ } catch {
52
+ return timestamp;
53
+ }
54
+ }
55
+
56
+ function formatLastActive(timestamp: string): string {
57
+ try {
58
+ const now = new Date();
59
+ const updated = new Date(timestamp);
60
+ const diffMs = now.getTime() - updated.getTime();
61
+ const diffMins = Math.floor(diffMs / 60000);
62
+ const diffHours = Math.floor(diffMs / 3600000);
63
+ const diffDays = Math.floor(diffMs / 86400000);
64
+
65
+ if (diffMins < 1) return 'Just now';
66
+ if (diffMins < 60) return `${diffMins} min ago`;
67
+ if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
68
+ if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
69
+ return formatDateTime(timestamp);
70
+ } catch {
71
+ return 'Unknown';
72
+ }
73
+ }
74
+
75
+ export function ProfilePage() {
76
+ const instance = usePiralInstance();
77
+ const pb = React.useMemo(
78
+ () => (instance as unknown as { root?: { pocketbase?: { authStore: { model: unknown } } } })?.root?.pocketbase,
79
+ [instance]
80
+ );
81
+
82
+ const userFromState = useGlobalState((s) => (s as { user?: { id?: string; mail?: string; firstName?: string; lastName?: string } }).user);
83
+ const [profileUser, setProfileUser] = React.useState<ProfileUser | null>(null);
84
+ const [profileLoading, setProfileLoading] = React.useState(true);
85
+
86
+ const { permissions, permissionsByCollection, loading: permissionsLoading } = usePermissions({
87
+ fetchAll: true,
88
+ });
89
+ const can = useCan();
90
+
91
+ React.useEffect(() => {
92
+ if (!pb || !userFromState?.id) {
93
+ setProfileUser(null);
94
+ setProfileLoading(false);
95
+ return;
96
+ }
97
+
98
+ let cancelled = false;
99
+
100
+ async function fetchProfile() {
101
+ try {
102
+ const record = await pb.collection('users').getOne(userFromState.id, {
103
+ expand: 'role',
104
+ $autoCancel: false,
105
+ });
106
+ if (cancelled) return;
107
+
108
+ const roleId = Array.isArray(record.role) ? record.role[0] : record.role;
109
+ const expandedRole = record.expand?.role;
110
+ const roleName = (Array.isArray(expandedRole) ? expandedRole[0]?.name : expandedRole?.name) ?? 'No role';
111
+
112
+ const name =
113
+ record.name ||
114
+ [record.firstName, record.lastName].filter(Boolean).join(' ').trim() ||
115
+ record.email ||
116
+ 'Unknown';
117
+
118
+ setProfileUser({
119
+ id: record.id,
120
+ name,
121
+ email: record.email ?? '',
122
+ avatar: record.avatar,
123
+ roleName: record.is_superuser ? 'Superuser' : roleName,
124
+ is_superuser: record.is_superuser ?? false,
125
+ isActive: record.isActive !== false,
126
+ created: record.created,
127
+ updated: record.updated,
128
+ verified: record.verified === true,
129
+ });
130
+ } catch (err) {
131
+ if (!cancelled) {
132
+ console.error('[ProfilePage] Error fetching profile:', err);
133
+ setProfileUser({
134
+ id: userFromState.id,
135
+ name: [userFromState.firstName, userFromState.lastName].filter(Boolean).join(' ').trim() || userFromState.mail || 'Unknown',
136
+ email: userFromState.mail ?? '',
137
+ roleName: 'Unknown',
138
+ is_superuser: false,
139
+ isActive: true,
140
+ verified: false,
141
+ });
142
+ }
143
+ } finally {
144
+ if (!cancelled) setProfileLoading(false);
145
+ }
146
+ }
147
+
148
+ fetchProfile();
149
+ return () => {
150
+ cancelled = true;
151
+ };
152
+ }, [pb, userFromState?.id, userFromState?.firstName, userFromState?.lastName, userFromState?.mail]);
153
+
154
+ const isSuperuser = profileUser?.is_superuser === true;
155
+ const loading = profileLoading || permissionsLoading;
156
+
157
+ const collections = React.useMemo(() => {
158
+ const cols = Object.keys(permissionsByCollection).sort();
159
+ return cols;
160
+ }, [permissionsByCollection]);
161
+
162
+ if (loading && !profileUser) {
163
+ return <Loading message="Loading profile..." fullPage={false} />;
164
+ }
165
+
166
+ return (
167
+ <div className="space-y-6 max-w-4xl mx-auto">
168
+ {/* Superuser Banner */}
169
+ {isSuperuser && (
170
+ <div className="rounded-lg border border-amber-500/50 bg-gradient-to-r from-amber-500/20 to-yellow-500/20 p-4 flex items-center gap-3">
171
+ <div className="w-10 h-10 rounded-full bg-amber-500/30 flex items-center justify-center">
172
+ <Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
173
+ </div>
174
+ <div>
175
+ <h3 className="font-semibold text-amber-900 dark:text-amber-100">
176
+ Superuser Account
177
+ </h3>
178
+ <p className="text-sm text-amber-800 dark:text-amber-200">
179
+ All permissions granted. You have full access to all system resources.
180
+ </p>
181
+ </div>
182
+ </div>
183
+ )}
184
+
185
+ {/* User Information Card */}
186
+ <Card>
187
+ <CardHeader>
188
+ <div className="flex items-center gap-4">
189
+ {profileUser?.avatar ? (
190
+ <img
191
+ src={profileUser.avatar}
192
+ alt={profileUser.name}
193
+ className="w-16 h-16 rounded-full object-cover"
194
+ />
195
+ ) : (
196
+ <div className="w-16 h-16 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white text-xl font-semibold">
197
+ {profileUser?.name
198
+ ? profileUser.name
199
+ .split(' ')
200
+ .map((n) => n[0])
201
+ .join('')
202
+ .toUpperCase()
203
+ .slice(0, 2)
204
+ : 'U'}
205
+ </div>
206
+ )}
207
+ <div>
208
+ <CardTitle>{profileUser?.name ?? 'User'}</CardTitle>
209
+ <CardDescription>{profileUser?.email}</CardDescription>
210
+ <div className="flex items-center gap-2 mt-2">
211
+ <Badge variant="default">{profileUser?.roleName ?? 'Unknown'}</Badge>
212
+ <Badge variant={profileUser?.isActive ? 'success' : 'destructive'}>
213
+ {profileUser?.isActive ? 'Active' : 'Inactive'}
214
+ </Badge>
215
+ {profileUser?.verified && (
216
+ <Badge variant="success" className="gap-1">
217
+ <Mail className="w-3 h-3" />
218
+ Verified
219
+ </Badge>
220
+ )}
221
+ </div>
222
+ </div>
223
+ </div>
224
+ </CardHeader>
225
+ </Card>
226
+
227
+ {/* Account Details - typical SaaS profile info */}
228
+ <Card>
229
+ <CardHeader>
230
+ <CardTitle className="flex items-center gap-2">
231
+ <User className="w-5 h-5" />
232
+ Account Details
233
+ </CardTitle>
234
+ <CardDescription>
235
+ Your account information and security status
236
+ </CardDescription>
237
+ </CardHeader>
238
+ <CardContent>
239
+ <dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
240
+ <div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
241
+ <Hash className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
242
+ <div>
243
+ <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wider">User ID</dt>
244
+ <dd className="text-sm font-mono mt-0.5 break-all">{profileUser?.id ?? '—'}</dd>
245
+ </div>
246
+ </div>
247
+ <div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
248
+ <Mail className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
249
+ <div>
250
+ <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Email</dt>
251
+ <dd className="text-sm mt-0.5">{profileUser?.email ?? '—'}</dd>
252
+ </div>
253
+ </div>
254
+ <div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
255
+ <Calendar className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
256
+ <div>
257
+ <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Member since</dt>
258
+ <dd className="text-sm mt-0.5">
259
+ {profileUser?.created ? formatDate(profileUser.created) : '—'}
260
+ </dd>
261
+ </div>
262
+ </div>
263
+ <div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
264
+ <Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
265
+ <div>
266
+ <dt className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Last active</dt>
267
+ <dd className="text-sm mt-0.5">
268
+ {profileUser?.updated ? formatLastActive(profileUser.updated) : '—'}
269
+ </dd>
270
+ </div>
271
+ </div>
272
+ </dl>
273
+ </CardContent>
274
+ </Card>
275
+
276
+ {/* Permissions List */}
277
+ <Card>
278
+ <CardHeader>
279
+ <CardTitle>Permissions</CardTitle>
280
+ <CardDescription>
281
+ Your assigned permissions across all collections. Check marks indicate granted access.
282
+ </CardDescription>
283
+ </CardHeader>
284
+ <CardContent>
285
+ {collections.length === 0 ? (
286
+ <p className="text-sm text-muted-foreground">No permissions defined.</p>
287
+ ) : (
288
+ <div className="space-y-6">
289
+ {collections.map((collection) => {
290
+ const perms = permissionsByCollection[collection] ?? [];
291
+ return (
292
+ <div key={collection}>
293
+ <h4 className="text-sm font-medium text-muted-foreground capitalize mb-2">
294
+ {collection}
295
+ </h4>
296
+ <div className="space-y-2">
297
+ {perms.map((perm) => {
298
+ const allowed = can(perm.action, perm.collection);
299
+ return (
300
+ <div
301
+ key={perm.id}
302
+ className="flex items-center justify-between py-2 px-3 rounded-lg bg-muted/50"
303
+ >
304
+ <div className="flex items-center gap-3">
305
+ {allowed ? (
306
+ <Check className="w-4 h-4 text-green-600 dark:text-green-400 flex-shrink-0" />
307
+ ) : (
308
+ <X className="w-4 h-4 text-red-500 dark:text-red-400 flex-shrink-0" />
309
+ )}
310
+ <span className="text-sm font-medium capitalize">
311
+ {perm.action} {perm.collection}
312
+ </span>
313
+ {perm.description && (
314
+ <span className="text-xs text-muted-foreground">
315
+ — {perm.description}
316
+ </span>
317
+ )}
318
+ </div>
319
+ <Badge variant={allowed ? 'success' : 'destructive'}>
320
+ {allowed ? 'Granted' : 'Denied'}
321
+ </Badge>
322
+ </div>
323
+ );
324
+ })}
325
+ </div>
326
+ </div>
327
+ );
328
+ })}
329
+ </div>
330
+ )}
331
+ </CardContent>
332
+ </Card>
333
+ </div>
334
+ );
335
+ }
@@ -0,0 +1,101 @@
1
+ import * as React from 'react';
2
+ import {
3
+ Card,
4
+ CardHeader,
5
+ CardTitle,
6
+ CardDescription,
7
+ CardContent,
8
+ ThemeToggle,
9
+ } from '@lego-box/ui-kit';
10
+ import { Palette, Bell, Globe } from 'lucide-react';
11
+
12
+ export function SettingsPage() {
13
+ return (
14
+ <div className="space-y-6">
15
+ <div className="page-header">
16
+ <div className="page-header-left">
17
+ <h1 className="text-2xl font-bold text-foreground">Settings</h1>
18
+ <p className="text-sm text-muted-foreground mt-1">
19
+ Manage your application preferences
20
+ </p>
21
+ </div>
22
+ </div>
23
+
24
+ {/* Appearance */}
25
+ <Card>
26
+ <CardHeader>
27
+ <div className="flex items-center gap-3">
28
+ <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
29
+ <Palette className="h-5 w-5 text-primary" />
30
+ </div>
31
+ <div>
32
+ <CardTitle>Appearance</CardTitle>
33
+ <CardDescription>
34
+ Customize how the application looks
35
+ </CardDescription>
36
+ </div>
37
+ </div>
38
+ </CardHeader>
39
+ <CardContent>
40
+ <div className="flex items-center justify-between rounded-lg border border-border p-4">
41
+ <div>
42
+ <p className="font-medium text-foreground">Theme</p>
43
+ <p className="text-sm text-muted-foreground">
44
+ Choose between light and dark mode
45
+ </p>
46
+ </div>
47
+ <ThemeToggle />
48
+ </div>
49
+ </CardContent>
50
+ </Card>
51
+
52
+ {/* Notifications (placeholder) */}
53
+ <Card>
54
+ <CardHeader>
55
+ <div className="flex items-center gap-3">
56
+ <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
57
+ <Bell className="h-5 w-5 text-primary" />
58
+ </div>
59
+ <div>
60
+ <CardTitle>Notifications</CardTitle>
61
+ <CardDescription>
62
+ Configure how you receive notifications
63
+ </CardDescription>
64
+ </div>
65
+ </div>
66
+ </CardHeader>
67
+ <CardContent>
68
+ <div className="rounded-lg border border-border p-4">
69
+ <p className="text-sm text-muted-foreground">
70
+ Notification preferences are coming soon.
71
+ </p>
72
+ </div>
73
+ </CardContent>
74
+ </Card>
75
+
76
+ {/* Language (placeholder) */}
77
+ <Card>
78
+ <CardHeader>
79
+ <div className="flex items-center gap-3">
80
+ <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
81
+ <Globe className="h-5 w-5 text-primary" />
82
+ </div>
83
+ <div>
84
+ <CardTitle>Language & Region</CardTitle>
85
+ <CardDescription>
86
+ Set your preferred language and region
87
+ </CardDescription>
88
+ </div>
89
+ </div>
90
+ </CardHeader>
91
+ <CardContent>
92
+ <div className="rounded-lg border border-border p-4">
93
+ <p className="text-sm text-muted-foreground">
94
+ Language and region settings are coming soon.
95
+ </p>
96
+ </div>
97
+ </CardContent>
98
+ </Card>
99
+ </div>
100
+ );
101
+ }
@@ -0,0 +1,144 @@
1
+ import * as React from 'react';
2
+ import {
3
+ HealthStatusCard,
4
+ ServiceStatusCard,
5
+ Button,
6
+ } from '@lego-box/ui-kit';
7
+ import { useGlobalStateContext } from 'piral';
8
+
9
+ type ServiceStatus = 'operational' | 'degraded' | 'down' | 'checking';
10
+
11
+ interface ServiceHealth {
12
+ name: string;
13
+ description: string;
14
+ status: ServiceStatus;
15
+ responseTime?: number;
16
+ details?: string;
17
+ lastChecked?: string;
18
+ }
19
+
20
+ type OverallHealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'checking';
21
+
22
+ export function SystemHealthCheckPage() {
23
+ const ctx = useGlobalStateContext();
24
+
25
+ const [isRefreshing, setIsRefreshing] = React.useState(false);
26
+ const [lastCheckTime, setLastCheckTime] = React.useState<Date | null>(null);
27
+
28
+ const [overallHealth, setOverallHealth] = React.useState<{
29
+ status: OverallHealthStatus;
30
+ percentage: number;
31
+ }>({ status: 'checking', percentage: 0 });
32
+
33
+ const [piralHealth, setPiralHealth] = React.useState<ServiceHealth>({ name: 'Piral Shell', description: 'Microfrontend orchestration', status: 'checking' });
34
+ const [pocketbaseHealth, setPocketbaseHealth] = React.useState<ServiceHealth>({ name: 'PocketBase', description: 'Backend database and auth', status: 'checking' });
35
+
36
+ const checkPiralHealth = React.useCallback(async (): Promise<ServiceHealth> => {
37
+ const startTime = performance.now();
38
+ try {
39
+ if (!ctx || !ctx.navigation) {
40
+ return { name: 'Piral Shell', description: 'Microfrontend orchestration', status: 'degraded', details: 'Piral context not fully available', lastChecked: new Date().toLocaleString() };
41
+ }
42
+ return { name: 'Piral Shell', description: 'Microfrontend orchestration', status: 'operational', responseTime: Math.round(performance.now() - startTime), details: 'Piral shell is running normally', lastChecked: new Date().toLocaleString() };
43
+ } catch (error) {
44
+ return { name: 'Piral Shell', description: 'Microfrontend orchestration', status: 'down', details: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`, lastChecked: new Date().toLocaleString() };
45
+ }
46
+ }, [ctx]);
47
+
48
+ const checkPocketbaseHealth = React.useCallback(async (): Promise<ServiceHealth> => {
49
+ const startTime = performance.now();
50
+ const pocketbaseUrl = 'http://localhost:8090';
51
+ try {
52
+ const response = await fetch(`${pocketbaseUrl}/api/health`);
53
+ const responseTime = Math.round(performance.now() - startTime);
54
+ if (response.ok) {
55
+ return { name: 'PocketBase', description: 'Backend database and auth', status: 'operational', responseTime, details: 'PocketBase is running normally', lastChecked: new Date().toLocaleString() };
56
+ }
57
+ return { name: 'PocketBase', description: 'Backend database and auth', status: 'down', responseTime, details: `HTTP ${response.status}: ${response.statusText}`, lastChecked: new Date().toLocaleString() };
58
+ } catch (error) {
59
+ return { name: 'PocketBase', description: 'Backend database and auth', status: 'down', details: `Connection failed: ${error instanceof Error ? error.message : 'Server unreachable'}`, lastChecked: new Date().toLocaleString() };
60
+ }
61
+ }, []);
62
+
63
+ const calculateOverallHealth = React.useCallback((piral: ServiceHealth, pocketbase: ServiceHealth) => {
64
+ const services = [piral, pocketbase];
65
+ const operational = services.filter(s => s.status === 'operational').length;
66
+ const degraded = services.filter(s => s.status === 'degraded').length;
67
+ let status: OverallHealthStatus = 'checking';
68
+ let percentage = 0;
69
+
70
+ if (operational === services.length) {
71
+ status = 'healthy';
72
+ percentage = 100;
73
+ } else if (degraded > 0) {
74
+ status = 'degraded';
75
+ percentage = 50;
76
+ } else {
77
+ status = 'unhealthy';
78
+ percentage = 0;
79
+ }
80
+
81
+ return { status, percentage };
82
+ }, []);
83
+
84
+ const runHealthChecks = React.useCallback(async () => {
85
+ setIsRefreshing(true);
86
+ setOverallHealth(prev => ({ ...prev, status: 'checking' }));
87
+ setPiralHealth(prev => ({ ...prev, status: 'checking' }));
88
+ setPocketbaseHealth(prev => ({ ...prev, status: 'checking' }));
89
+
90
+ const [piralResult, pocketbaseResult] = await Promise.all([checkPiralHealth(), checkPocketbaseHealth()]);
91
+
92
+ setPiralHealth(piralResult);
93
+ setPocketbaseHealth(pocketbaseResult);
94
+ setOverallHealth(calculateOverallHealth(piralResult, pocketbaseResult));
95
+ setLastCheckTime(new Date());
96
+ setIsRefreshing(false);
97
+ }, [checkPiralHealth, checkPocketbaseHealth, calculateOverallHealth]);
98
+
99
+ React.useEffect(() => { runHealthChecks(); }, [runHealthChecks]);
100
+ React.useEffect(() => {
101
+ const interval = setInterval(() => { runHealthChecks(); }, 30000);
102
+ return () => clearInterval(interval);
103
+ }, [runHealthChecks]);
104
+
105
+ const formatLastCheck = () => {
106
+ if (!lastCheckTime) return 'Never';
107
+ const diff = Math.floor((Date.now() - lastCheckTime.getTime()) / 1000);
108
+ if (diff < 60) return 'Just now';
109
+ return lastCheckTime.toLocaleString();
110
+ };
111
+
112
+ return (
113
+ <div className="space-y-6">
114
+ <div className="page-header">
115
+ <div className="page-header-left">
116
+ <h1 className="text-2xl font-bold text-foreground">System Health</h1>
117
+ <p className="text-sm text-muted-foreground mt-1">Monitor platform health and services status</p>
118
+ </div>
119
+ <div className="page-header-right">
120
+ <Button variant="outline" onClick={runHealthChecks} disabled={isRefreshing}>Refresh</Button>
121
+ </div>
122
+ </div>
123
+
124
+ <div className="flex items-center gap-2 text-sm">
125
+ <div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
126
+ <span className="text-emerald-600 dark:text-emerald-400">Monitoring active</span>
127
+ <div className="w-px h-4 bg-border mx-2" />
128
+ <span className="text-muted-foreground">Last checked: {formatLastCheck()}</span>
129
+ </div>
130
+
131
+ <div className="grid gap-6">
132
+ <HealthStatusCard
133
+ status={overallHealth.status}
134
+ percentage={overallHealth.percentage}
135
+ lastCheck={lastCheckTime?.toLocaleString()}
136
+ />
137
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
138
+ <ServiceStatusCard {...piralHealth} icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /></svg>} />
139
+ <ServiceStatusCard {...pocketbaseHealth} icon={<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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" /></svg>} />
140
+ </div>
141
+ </div>
142
+ </div>
143
+ );
144
+ }