@tidecloak/ui-framework 0.0.1
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/README.md +377 -0
- package/dist/index.d.mts +2739 -0
- package/dist/index.d.ts +2739 -0
- package/dist/index.js +12869 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +12703 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +54 -0
- package/src/components/common/ActionButton.tsx +234 -0
- package/src/components/common/EmptyState.tsx +140 -0
- package/src/components/common/LoadingSkeleton.tsx +121 -0
- package/src/components/common/RefreshButton.tsx +127 -0
- package/src/components/common/StatusBadge.tsx +177 -0
- package/src/components/common/index.ts +31 -0
- package/src/components/data-table/DataTable.tsx +201 -0
- package/src/components/data-table/PaginatedTable.tsx +247 -0
- package/src/components/data-table/index.ts +2 -0
- package/src/components/dialogs/CollapsibleSection.tsx +184 -0
- package/src/components/dialogs/ConfirmDialog.tsx +264 -0
- package/src/components/dialogs/DetailDialog.tsx +228 -0
- package/src/components/dialogs/index.ts +3 -0
- package/src/components/index.ts +5 -0
- package/src/components/pages/base/ApprovalsPageBase.tsx +680 -0
- package/src/components/pages/base/LogsPageBase.tsx +581 -0
- package/src/components/pages/base/RolesPageBase.tsx +1470 -0
- package/src/components/pages/base/TemplatesPageBase.tsx +761 -0
- package/src/components/pages/base/UsersPageBase.tsx +843 -0
- package/src/components/pages/base/index.ts +58 -0
- package/src/components/pages/connected/ApprovalsPage.tsx +797 -0
- package/src/components/pages/connected/LogsPage.tsx +267 -0
- package/src/components/pages/connected/RolesPage.tsx +525 -0
- package/src/components/pages/connected/TemplatesPage.tsx +181 -0
- package/src/components/pages/connected/UsersPage.tsx +237 -0
- package/src/components/pages/connected/index.ts +36 -0
- package/src/components/pages/index.ts +5 -0
- package/src/components/tabs/TabsView.tsx +300 -0
- package/src/components/tabs/index.ts +1 -0
- package/src/components/ui/index.tsx +1001 -0
- package/src/hooks/index.ts +3 -0
- package/src/hooks/useAutoRefresh.ts +119 -0
- package/src/hooks/usePagination.ts +152 -0
- package/src/hooks/useSelection.ts +81 -0
- package/src/index.ts +256 -0
- package/src/theme.ts +185 -0
- package/src/tide/index.ts +19 -0
- package/src/tide/tidePolicy.ts +270 -0
- package/src/types/index.ts +484 -0
- package/src/utils/index.ts +121 -0
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
import React, { useState, useEffect, useRef } from "react";
|
|
2
|
+
import { ScrollText, Activity, Shield, Plus, Check, X, Upload, Ban, Eye } from "lucide-react";
|
|
3
|
+
import { formatLogTimestamp, computeRowsForViewportHeight } from "../../../utils";
|
|
4
|
+
import { useAutoRefresh } from "../../../hooks/useAutoRefresh";
|
|
5
|
+
import { usePagination } from "../../../hooks/usePagination";
|
|
6
|
+
import { RefreshButton } from "../../common/RefreshButton";
|
|
7
|
+
import { StatusBadge } from "../../common/StatusBadge";
|
|
8
|
+
import { ActionButton } from "../../common/ActionButton";
|
|
9
|
+
import { LoadingSkeleton } from "../../common/LoadingSkeleton";
|
|
10
|
+
import { EmptyState } from "../../common/EmptyState";
|
|
11
|
+
import { TabsView } from "../../tabs/TabsView";
|
|
12
|
+
import { DataTable } from "../../data-table/DataTable";
|
|
13
|
+
import { DetailDialog } from "../../dialogs/DetailDialog";
|
|
14
|
+
import { defaultComponents } from "../../ui";
|
|
15
|
+
import type { BaseDataItem, ColumnDef, DetailField, ToastConfig } from "../../../types";
|
|
16
|
+
|
|
17
|
+
export interface BaseLogItem extends BaseDataItem {
|
|
18
|
+
timestamp: number; // Unix timestamp in milliseconds
|
|
19
|
+
type?: string;
|
|
20
|
+
action?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface AccessLogItem extends BaseLogItem {
|
|
24
|
+
type: string;
|
|
25
|
+
clientId?: string;
|
|
26
|
+
userId?: string;
|
|
27
|
+
ipAddress?: string;
|
|
28
|
+
details?: Record<string, any>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PolicyLogItem extends BaseLogItem {
|
|
32
|
+
policyId: string;
|
|
33
|
+
roleId: string;
|
|
34
|
+
action: string;
|
|
35
|
+
performedBy: string;
|
|
36
|
+
performedByEmail?: string;
|
|
37
|
+
details?: string;
|
|
38
|
+
policyStatus?: string;
|
|
39
|
+
policyThreshold?: number;
|
|
40
|
+
approvalCount?: number;
|
|
41
|
+
rejectionCount?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LogsTabConfig<T extends BaseLogItem> {
|
|
45
|
+
key: string;
|
|
46
|
+
label: string;
|
|
47
|
+
icon?: React.ReactNode;
|
|
48
|
+
/** Data fetcher - receives limit and offset */
|
|
49
|
+
fetchData: (limit: number, offset: number) => Promise<T[]>;
|
|
50
|
+
/** Column definitions for the table */
|
|
51
|
+
columns: ColumnDef<T>[];
|
|
52
|
+
/** Empty state configuration */
|
|
53
|
+
emptyState: {
|
|
54
|
+
icon: React.ReactNode;
|
|
55
|
+
title: string;
|
|
56
|
+
description: string;
|
|
57
|
+
};
|
|
58
|
+
/** Optional row click handler */
|
|
59
|
+
onRowClick?: (item: T) => void;
|
|
60
|
+
/** Query keys for cache invalidation */
|
|
61
|
+
queryKeys?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface LogsPageBaseProps<
|
|
65
|
+
TAccess extends AccessLogItem = AccessLogItem,
|
|
66
|
+
TPolicy extends PolicyLogItem = PolicyLogItem
|
|
67
|
+
> {
|
|
68
|
+
/** Page title */
|
|
69
|
+
title?: string;
|
|
70
|
+
/** Page description */
|
|
71
|
+
description?: string;
|
|
72
|
+
/** Tab configurations */
|
|
73
|
+
tabs: {
|
|
74
|
+
access?: LogsTabConfig<TAccess>;
|
|
75
|
+
sessions?: {
|
|
76
|
+
key: string;
|
|
77
|
+
label: string;
|
|
78
|
+
icon?: React.ReactNode;
|
|
79
|
+
/** Embedded content component */
|
|
80
|
+
content: React.ReactNode;
|
|
81
|
+
};
|
|
82
|
+
policies?: LogsTabConfig<TPolicy>;
|
|
83
|
+
};
|
|
84
|
+
/** Initial active tab */
|
|
85
|
+
initialTab?: "access" | "sessions" | "policies";
|
|
86
|
+
/** Toast notification handler */
|
|
87
|
+
toast?: (config: ToastConfig) => void;
|
|
88
|
+
/** Auto-refresh interval in seconds */
|
|
89
|
+
refreshInterval?: number;
|
|
90
|
+
/** Query invalidation handler */
|
|
91
|
+
invalidateQueries?: (queryKeys: string[]) => void;
|
|
92
|
+
/** Available page sizes */
|
|
93
|
+
pageSizes?: number[];
|
|
94
|
+
/** Enable auto page sizing */
|
|
95
|
+
autoPageSize?: boolean;
|
|
96
|
+
/** URL state management */
|
|
97
|
+
urlState?: {
|
|
98
|
+
tab: string;
|
|
99
|
+
setTab: (tab: string) => void;
|
|
100
|
+
};
|
|
101
|
+
/** Custom components */
|
|
102
|
+
components?: {
|
|
103
|
+
Card?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
|
|
104
|
+
CardContent?: React.ComponentType<{ children: React.ReactNode; className?: string }>;
|
|
105
|
+
Button?: React.ComponentType<any>;
|
|
106
|
+
Badge?: React.ComponentType<any>;
|
|
107
|
+
Skeleton?: React.ComponentType<{ className?: string }>;
|
|
108
|
+
Select?: React.ComponentType<any>;
|
|
109
|
+
SelectTrigger?: React.ComponentType<any>;
|
|
110
|
+
SelectValue?: React.ComponentType<any>;
|
|
111
|
+
SelectContent?: React.ComponentType<any>;
|
|
112
|
+
SelectItem?: React.ComponentType<any>;
|
|
113
|
+
// Table components
|
|
114
|
+
Table?: React.ComponentType<any>;
|
|
115
|
+
TableHeader?: React.ComponentType<any>;
|
|
116
|
+
TableBody?: React.ComponentType<any>;
|
|
117
|
+
TableRow?: React.ComponentType<any>;
|
|
118
|
+
TableHead?: React.ComponentType<any>;
|
|
119
|
+
TableCell?: React.ComponentType<any>;
|
|
120
|
+
// Tabs components
|
|
121
|
+
Tabs?: React.ComponentType<any>;
|
|
122
|
+
TabsList?: React.ComponentType<any>;
|
|
123
|
+
TabsTrigger?: React.ComponentType<any>;
|
|
124
|
+
TabsContent?: React.ComponentType<any>;
|
|
125
|
+
// Dialog components
|
|
126
|
+
Dialog?: React.ComponentType<any>;
|
|
127
|
+
DialogContent?: React.ComponentType<any>;
|
|
128
|
+
DialogHeader?: React.ComponentType<any>;
|
|
129
|
+
DialogTitle?: React.ComponentType<any>;
|
|
130
|
+
DialogDescription?: React.ComponentType<any>;
|
|
131
|
+
DialogFooter?: React.ComponentType<any>;
|
|
132
|
+
};
|
|
133
|
+
/** Additional class name */
|
|
134
|
+
className?: string;
|
|
135
|
+
/** Optional help text shown below description */
|
|
136
|
+
helpText?: React.ReactNode;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ============================================================================
|
|
140
|
+
// GENERIC LOGS TAB COMPONENT
|
|
141
|
+
// ============================================================================
|
|
142
|
+
|
|
143
|
+
interface LogsTabProps<T extends BaseLogItem> {
|
|
144
|
+
config: LogsTabConfig<T>;
|
|
145
|
+
refreshInterval: number;
|
|
146
|
+
toast?: (config: ToastConfig) => void;
|
|
147
|
+
invalidateQueries?: (queryKeys: string[]) => void;
|
|
148
|
+
pageSizes: number[];
|
|
149
|
+
autoPageSize: boolean;
|
|
150
|
+
components?: LogsPageBaseProps['components'];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function LogsTab<T extends BaseLogItem>({
|
|
154
|
+
config,
|
|
155
|
+
refreshInterval,
|
|
156
|
+
toast,
|
|
157
|
+
invalidateQueries,
|
|
158
|
+
pageSizes,
|
|
159
|
+
autoPageSize,
|
|
160
|
+
components = {},
|
|
161
|
+
}: LogsTabProps<T>) {
|
|
162
|
+
const [data, setData] = useState<T[]>([]);
|
|
163
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
164
|
+
const viewportRef = useRef<HTMLDivElement>(null);
|
|
165
|
+
|
|
166
|
+
// Pagination
|
|
167
|
+
const pagination = usePagination({
|
|
168
|
+
initialPageSize: autoPageSize ? 50 : pageSizes[0],
|
|
169
|
+
autoPageSize,
|
|
170
|
+
viewportRef: autoPageSize ? viewportRef : undefined,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const fetchData = async () => {
|
|
174
|
+
setIsLoading(true);
|
|
175
|
+
try {
|
|
176
|
+
const result = await config.fetchData(
|
|
177
|
+
pagination.pageSize,
|
|
178
|
+
pagination.page * pagination.pageSize
|
|
179
|
+
);
|
|
180
|
+
setData(result);
|
|
181
|
+
if (invalidateQueries && config.queryKeys) {
|
|
182
|
+
invalidateQueries(config.queryKeys);
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.error(`Error fetching ${config.key} logs:`, error);
|
|
186
|
+
toast?.({
|
|
187
|
+
title: `Failed to fetch ${config.label.toLowerCase()}`,
|
|
188
|
+
description: error instanceof Error ? error.message : "Unknown error",
|
|
189
|
+
variant: "destructive",
|
|
190
|
+
});
|
|
191
|
+
} finally {
|
|
192
|
+
setIsLoading(false);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const { secondsRemaining, refreshNow } = useAutoRefresh({
|
|
197
|
+
intervalSeconds: refreshInterval,
|
|
198
|
+
refresh: fetchData,
|
|
199
|
+
isBlocked: isLoading,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Fetch on mount and when pagination changes
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
void fetchData();
|
|
205
|
+
}, [pagination.page, pagination.pageSize]);
|
|
206
|
+
|
|
207
|
+
const hasNextPage = data.length === pagination.pageSize;
|
|
208
|
+
|
|
209
|
+
// Components (use defaultComponents from ui if not provided)
|
|
210
|
+
const Card = components.Card || defaultComponents.Card;
|
|
211
|
+
const CardContent = components.CardContent || defaultComponents.CardContent;
|
|
212
|
+
const Button = components.Button || defaultComponents.Button;
|
|
213
|
+
const Select = components.Select || defaultComponents.Select;
|
|
214
|
+
const SelectTrigger = components.SelectTrigger || defaultComponents.SelectTrigger;
|
|
215
|
+
const SelectValue = components.SelectValue || defaultComponents.SelectValue;
|
|
216
|
+
const SelectContent = components.SelectContent || defaultComponents.SelectContent;
|
|
217
|
+
const SelectItem = components.SelectItem || defaultComponents.SelectItem;
|
|
218
|
+
|
|
219
|
+
const hasSelectComponents = Select && SelectTrigger && SelectValue && SelectContent && SelectItem;
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<Card>
|
|
223
|
+
<CardContent style={{ padding: 0 }}>
|
|
224
|
+
{/* Pagination Controls */}
|
|
225
|
+
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.5rem", padding: "0.75rem 1rem", borderBottom: "1px solid #e5e7eb" }}>
|
|
226
|
+
<div style={{ fontSize: "0.875rem", color: "#6b7280" }}>
|
|
227
|
+
Page <span style={{ fontWeight: 500, color: "#111827" }}>{pagination.page + 1}</span>
|
|
228
|
+
</div>
|
|
229
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
|
230
|
+
{/* Page Size Selector */}
|
|
231
|
+
{hasSelectComponents && (
|
|
232
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", fontSize: "0.875rem", color: "#6b7280" }}>
|
|
233
|
+
Page size
|
|
234
|
+
<Select
|
|
235
|
+
value={pagination.pageSizeSelectValue}
|
|
236
|
+
onValueChange={pagination.onPageSizeSelect}
|
|
237
|
+
>
|
|
238
|
+
<SelectTrigger style={{ height: "2rem", width: "5.75rem" }}>
|
|
239
|
+
<SelectValue />
|
|
240
|
+
</SelectTrigger>
|
|
241
|
+
<SelectContent>
|
|
242
|
+
{autoPageSize && <SelectItem value="auto">Auto</SelectItem>}
|
|
243
|
+
{pageSizes.map((size) => (
|
|
244
|
+
<SelectItem key={size} value={String(size)}>
|
|
245
|
+
{size}
|
|
246
|
+
</SelectItem>
|
|
247
|
+
))}
|
|
248
|
+
</SelectContent>
|
|
249
|
+
</Select>
|
|
250
|
+
</div>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{/* Navigation */}
|
|
254
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
255
|
+
<Button
|
|
256
|
+
size="sm"
|
|
257
|
+
variant="outline"
|
|
258
|
+
onClick={() => pagination.setPage(Math.max(0, pagination.page - 1))}
|
|
259
|
+
disabled={pagination.page === 0 || isLoading}
|
|
260
|
+
>
|
|
261
|
+
Previous
|
|
262
|
+
</Button>
|
|
263
|
+
<Button
|
|
264
|
+
size="sm"
|
|
265
|
+
variant="outline"
|
|
266
|
+
onClick={() => pagination.setPage(pagination.page + 1)}
|
|
267
|
+
disabled={!hasNextPage || isLoading}
|
|
268
|
+
>
|
|
269
|
+
Next
|
|
270
|
+
</Button>
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
|
|
275
|
+
{/* Content */}
|
|
276
|
+
{isLoading && data.length === 0 ? (
|
|
277
|
+
<LoadingSkeleton rows={5} type="list" SkeletonComponent={components.Skeleton} />
|
|
278
|
+
) : data.length > 0 ? (
|
|
279
|
+
<div
|
|
280
|
+
ref={viewportRef}
|
|
281
|
+
style={autoPageSize ? { height: "max(320px, calc(100vh - 360px))", overflow: "auto" } : undefined}
|
|
282
|
+
>
|
|
283
|
+
<div style={{ minWidth: "850px" }}>
|
|
284
|
+
<DataTable
|
|
285
|
+
data={data}
|
|
286
|
+
columns={config.columns}
|
|
287
|
+
onRowClick={config.onRowClick}
|
|
288
|
+
components={{
|
|
289
|
+
Table: components.Table,
|
|
290
|
+
TableHeader: components.TableHeader,
|
|
291
|
+
TableBody: components.TableBody,
|
|
292
|
+
TableRow: components.TableRow,
|
|
293
|
+
TableHead: components.TableHead,
|
|
294
|
+
TableCell: components.TableCell,
|
|
295
|
+
Skeleton: components.Skeleton,
|
|
296
|
+
}}
|
|
297
|
+
/>
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
) : (
|
|
301
|
+
<EmptyState
|
|
302
|
+
icon={config.emptyState.icon}
|
|
303
|
+
title={config.emptyState.title}
|
|
304
|
+
description={config.emptyState.description}
|
|
305
|
+
/>
|
|
306
|
+
)}
|
|
307
|
+
</CardContent>
|
|
308
|
+
</Card>
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ============================================================================
|
|
313
|
+
// MAIN LOGS PAGE COMPONENT
|
|
314
|
+
// ============================================================================
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* LogsPage - Generic logs page with tabs for access, sessions, and policies
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* ```tsx
|
|
321
|
+
* <LogsPage
|
|
322
|
+
* tabs={{
|
|
323
|
+
* access: {
|
|
324
|
+
* key: 'access',
|
|
325
|
+
* label: 'Access',
|
|
326
|
+
* fetchData: (limit, offset) => api.admin.logs.access(limit, offset),
|
|
327
|
+
* columns: accessColumns,
|
|
328
|
+
* emptyState: { icon: <ScrollText />, title: 'No logs', description: '...' },
|
|
329
|
+
* },
|
|
330
|
+
* sessions: {
|
|
331
|
+
* key: 'sessions',
|
|
332
|
+
* label: 'Sessions',
|
|
333
|
+
* content: <SessionHistoryContent />,
|
|
334
|
+
* },
|
|
335
|
+
* policies: {
|
|
336
|
+
* key: 'policies',
|
|
337
|
+
* label: 'Policies',
|
|
338
|
+
* fetchData: (limit, offset) => api.admin.sshPolicies.getLogs(limit, offset),
|
|
339
|
+
* columns: policyColumns,
|
|
340
|
+
* emptyState: { icon: <Shield />, title: 'No policy logs', description: '...' },
|
|
341
|
+
* },
|
|
342
|
+
* }}
|
|
343
|
+
* />
|
|
344
|
+
* ```
|
|
345
|
+
*/
|
|
346
|
+
export function LogsPageBase<
|
|
347
|
+
TAccess extends AccessLogItem = AccessLogItem,
|
|
348
|
+
TPolicy extends PolicyLogItem = PolicyLogItem
|
|
349
|
+
>({
|
|
350
|
+
title = "Logs",
|
|
351
|
+
description = "Review access changes and activity",
|
|
352
|
+
tabs,
|
|
353
|
+
initialTab = "access",
|
|
354
|
+
toast,
|
|
355
|
+
refreshInterval = 15,
|
|
356
|
+
invalidateQueries,
|
|
357
|
+
pageSizes = [25, 50, 100, 200],
|
|
358
|
+
autoPageSize = true,
|
|
359
|
+
urlState,
|
|
360
|
+
components = {},
|
|
361
|
+
className,
|
|
362
|
+
helpText,
|
|
363
|
+
}: LogsPageBaseProps<TAccess, TPolicy>) {
|
|
364
|
+
const [activeTab, setActiveTab] = useState<string>(urlState?.tab || initialTab);
|
|
365
|
+
|
|
366
|
+
// Sync with URL state
|
|
367
|
+
useEffect(() => {
|
|
368
|
+
if (urlState?.tab && urlState.tab !== activeTab) {
|
|
369
|
+
setActiveTab(urlState.tab);
|
|
370
|
+
}
|
|
371
|
+
}, [urlState?.tab]);
|
|
372
|
+
|
|
373
|
+
const handleTabChange = (tab: string) => {
|
|
374
|
+
setActiveTab(tab);
|
|
375
|
+
urlState?.setTab(tab);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
// Build tab definitions
|
|
379
|
+
const tabDefs = [];
|
|
380
|
+
|
|
381
|
+
if (tabs.access) {
|
|
382
|
+
tabDefs.push({
|
|
383
|
+
key: "access",
|
|
384
|
+
label: tabs.access.label || "Access",
|
|
385
|
+
icon: tabs.access.icon,
|
|
386
|
+
content: (
|
|
387
|
+
<LogsTab
|
|
388
|
+
config={tabs.access as LogsTabConfig<any>}
|
|
389
|
+
refreshInterval={refreshInterval}
|
|
390
|
+
toast={toast}
|
|
391
|
+
invalidateQueries={invalidateQueries}
|
|
392
|
+
pageSizes={pageSizes}
|
|
393
|
+
autoPageSize={autoPageSize}
|
|
394
|
+
components={components}
|
|
395
|
+
/>
|
|
396
|
+
),
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (tabs.sessions) {
|
|
401
|
+
tabDefs.push({
|
|
402
|
+
key: "sessions",
|
|
403
|
+
label: tabs.sessions.label || "Sessions",
|
|
404
|
+
icon: tabs.sessions.icon || <Activity style={{ height: "1rem", width: "1rem" }} />,
|
|
405
|
+
content: tabs.sessions.content,
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (tabs.policies) {
|
|
410
|
+
tabDefs.push({
|
|
411
|
+
key: "policies",
|
|
412
|
+
label: tabs.policies.label || "Policies",
|
|
413
|
+
icon: tabs.policies.icon || <Shield style={{ height: "1rem", width: "1rem" }} />,
|
|
414
|
+
content: (
|
|
415
|
+
<LogsTab
|
|
416
|
+
config={tabs.policies as LogsTabConfig<any>}
|
|
417
|
+
refreshInterval={refreshInterval}
|
|
418
|
+
toast={toast}
|
|
419
|
+
invalidateQueries={invalidateQueries}
|
|
420
|
+
pageSizes={pageSizes}
|
|
421
|
+
autoPageSize={autoPageSize}
|
|
422
|
+
components={components}
|
|
423
|
+
/>
|
|
424
|
+
),
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Get current isFetching state for refresh button
|
|
429
|
+
const [isFetching, setIsFetching] = useState(false);
|
|
430
|
+
|
|
431
|
+
const { secondsRemaining, refreshNow } = useAutoRefresh({
|
|
432
|
+
intervalSeconds: refreshInterval,
|
|
433
|
+
refresh: async () => {
|
|
434
|
+
// Trigger refetch - actual fetching happens in tabs
|
|
435
|
+
},
|
|
436
|
+
isBlocked: isFetching,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
return (
|
|
440
|
+
<div style={{ padding: "1.5rem" }} className={className}>
|
|
441
|
+
{/* Header */}
|
|
442
|
+
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "1.5rem", flexWrap: "wrap", gap: "1rem" }}>
|
|
443
|
+
<div>
|
|
444
|
+
<h1 style={{ fontSize: "1.5rem", fontWeight: 600, display: "flex", alignItems: "center", gap: "0.5rem", margin: 0 }}>
|
|
445
|
+
<ScrollText style={{ width: "1.5rem", height: "1.5rem" }} />
|
|
446
|
+
{title}
|
|
447
|
+
</h1>
|
|
448
|
+
<p style={{ fontSize: "0.875rem", color: "#6b7280", margin: "0.25rem 0 0" }}>{description}</p>
|
|
449
|
+
</div>
|
|
450
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
451
|
+
<RefreshButton
|
|
452
|
+
onClick={() => void refreshNow()}
|
|
453
|
+
isRefreshing={isFetching}
|
|
454
|
+
secondsRemaining={secondsRemaining}
|
|
455
|
+
title="Refresh now"
|
|
456
|
+
/>
|
|
457
|
+
</div>
|
|
458
|
+
</div>
|
|
459
|
+
|
|
460
|
+
{/* Help text */}
|
|
461
|
+
{helpText && <div style={{ marginBottom: "1rem" }}>{helpText}</div>}
|
|
462
|
+
|
|
463
|
+
{/* Tabs */}
|
|
464
|
+
<TabsView
|
|
465
|
+
tabs={tabDefs}
|
|
466
|
+
activeTab={activeTab}
|
|
467
|
+
onTabChange={handleTabChange}
|
|
468
|
+
components={{
|
|
469
|
+
Tabs: components.Tabs,
|
|
470
|
+
TabsList: components.TabsList,
|
|
471
|
+
TabsTrigger: components.TabsTrigger,
|
|
472
|
+
TabsContent: components.TabsContent,
|
|
473
|
+
Badge: components.Badge,
|
|
474
|
+
}}
|
|
475
|
+
/>
|
|
476
|
+
</div>
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ============================================================================
|
|
481
|
+
// HELPER FUNCTIONS FOR CREATING COMMON COLUMNS
|
|
482
|
+
// ============================================================================
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Create timestamp column for logs
|
|
486
|
+
*/
|
|
487
|
+
export function createLogTimestampColumn<T extends { timestamp?: number; createdAt?: number; time?: number }>(
|
|
488
|
+
options?: { header?: string; field?: "timestamp" | "createdAt" | "time" }
|
|
489
|
+
): ColumnDef<T> {
|
|
490
|
+
return {
|
|
491
|
+
key: "timestamp",
|
|
492
|
+
header: options?.header || "Timestamp",
|
|
493
|
+
cell: (item) => {
|
|
494
|
+
const field = options?.field || "timestamp";
|
|
495
|
+
let ts = (item as any)[field];
|
|
496
|
+
|
|
497
|
+
// Handle various timestamp formats
|
|
498
|
+
if (field === "createdAt" && ts) {
|
|
499
|
+
ts = ts * 1000; // Convert seconds to ms
|
|
500
|
+
} else if (field === "time") {
|
|
501
|
+
ts = (item as any).time;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
return (
|
|
505
|
+
<span style={{ fontSize: "0.875rem", whiteSpace: "nowrap" }}>
|
|
506
|
+
{ts ? formatLogTimestamp(ts) : "-"}
|
|
507
|
+
</span>
|
|
508
|
+
);
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Create action column with icon and color
|
|
515
|
+
*/
|
|
516
|
+
export function createActionColumn<T extends { action: string }>(): ColumnDef<T> {
|
|
517
|
+
const iconStyle = { height: "1rem", width: "1rem" };
|
|
518
|
+
|
|
519
|
+
const getActionIcon = (action: string) => {
|
|
520
|
+
switch (action) {
|
|
521
|
+
case "created": return <Plus style={iconStyle} />;
|
|
522
|
+
case "approved": return <Check style={iconStyle} />;
|
|
523
|
+
case "rejected":
|
|
524
|
+
case "denied": return <X style={iconStyle} />;
|
|
525
|
+
case "committed": return <Upload style={iconStyle} />;
|
|
526
|
+
case "cancelled": return <Ban style={iconStyle} />;
|
|
527
|
+
default: return <Activity style={iconStyle} />;
|
|
528
|
+
}
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
const getActionColor = (action: string): string => {
|
|
532
|
+
switch (action) {
|
|
533
|
+
case "created": return "#2563eb";
|
|
534
|
+
case "approved": return "#16a34a";
|
|
535
|
+
case "rejected":
|
|
536
|
+
case "denied": return "#dc2626";
|
|
537
|
+
case "committed": return "#9333ea";
|
|
538
|
+
case "cancelled": return "#6b7280";
|
|
539
|
+
default: return "#6b7280";
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
key: "action",
|
|
545
|
+
header: "Action",
|
|
546
|
+
cell: (item) => (
|
|
547
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.375rem", fontSize: "0.875rem", fontWeight: 500, color: getActionColor(item.action) }}>
|
|
548
|
+
{getActionIcon(item.action)}
|
|
549
|
+
<span style={{ textTransform: "capitalize" }}>{item.action === "denied" ? "rejected" : item.action}</span>
|
|
550
|
+
</div>
|
|
551
|
+
),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Create progress column for policy logs
|
|
557
|
+
*/
|
|
558
|
+
export function createLogProgressColumn<T extends { policyThreshold?: number; approvalCount?: number; rejectionCount?: number }>(): ColumnDef<T> {
|
|
559
|
+
return {
|
|
560
|
+
key: "progress",
|
|
561
|
+
header: "Progress",
|
|
562
|
+
cell: (item) => {
|
|
563
|
+
if (!item.policyThreshold) {
|
|
564
|
+
return <span style={{ color: "#6b7280" }}>-</span>;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return (
|
|
568
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem", fontSize: "0.875rem" }}>
|
|
569
|
+
<span style={{ color: "#16a34a" }}>{item.approvalCount || 0}</span>
|
|
570
|
+
<span style={{ color: "#6b7280" }}>/</span>
|
|
571
|
+
<span>{item.policyThreshold}</span>
|
|
572
|
+
{(item.rejectionCount ?? 0) > 0 && (
|
|
573
|
+
<span style={{ color: "#dc2626", fontSize: "0.75rem", marginLeft: "0.25rem" }}>
|
|
574
|
+
({item.rejectionCount} ✗)
|
|
575
|
+
</span>
|
|
576
|
+
)}
|
|
577
|
+
</div>
|
|
578
|
+
);
|
|
579
|
+
},
|
|
580
|
+
};
|
|
581
|
+
}
|