@oneuptime/common 10.0.28 → 10.0.30

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 (70) hide show
  1. package/Models/DatabaseModels/Index.ts +2 -0
  2. package/Models/DatabaseModels/LogSavedView.ts +466 -0
  3. package/Server/API/TelemetryAPI.ts +146 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/1772355000000-AddLogSavedView.ts +48 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1773344537755-MigrationName.ts +91 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  7. package/Server/Services/LogAggregationService.ts +387 -0
  8. package/Server/Services/LogSavedViewService.ts +109 -0
  9. package/Server/Types/Workflow/Components/BaseModel/OnTriggerBaseModel.ts +15 -0
  10. package/Server/Utils/Express.ts +1 -0
  11. package/Server/Utils/OpenAPI.ts +28 -0
  12. package/Server/Utils/StartServer.ts +20 -1
  13. package/UI/Components/LogsViewer/LogsViewer.tsx +204 -64
  14. package/UI/Components/LogsViewer/components/ColumnSelector.tsx +270 -0
  15. package/UI/Components/LogsViewer/components/LiveLogsToggle.tsx +3 -3
  16. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +2 -2
  17. package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +699 -0
  18. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +46 -1
  19. package/UI/Components/LogsViewer/components/LogsFilterCard.tsx +3 -3
  20. package/UI/Components/LogsViewer/components/LogsTable.tsx +288 -103
  21. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +113 -11
  22. package/UI/Components/LogsViewer/components/SavedViewsDropdown.tsx +175 -0
  23. package/UI/Components/LogsViewer/types.ts +96 -0
  24. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  25. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  26. package/build/dist/Models/DatabaseModels/LogSavedView.js +496 -0
  27. package/build/dist/Models/DatabaseModels/LogSavedView.js.map +1 -0
  28. package/build/dist/Server/API/TelemetryAPI.js +88 -0
  29. package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
  30. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772355000000-AddLogSavedView.js +44 -0
  31. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772355000000-AddLogSavedView.js.map +1 -0
  32. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773344537755-MigrationName.js +38 -0
  33. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773344537755-MigrationName.js.map +1 -0
  34. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  35. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  36. package/build/dist/Server/Services/LogAggregationService.js +249 -0
  37. package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
  38. package/build/dist/Server/Services/LogSavedViewService.js +82 -0
  39. package/build/dist/Server/Services/LogSavedViewService.js.map +1 -0
  40. package/build/dist/Server/Types/Workflow/Components/BaseModel/OnTriggerBaseModel.js +15 -0
  41. package/build/dist/Server/Types/Workflow/Components/BaseModel/OnTriggerBaseModel.js.map +1 -1
  42. package/build/dist/Server/Utils/Express.js +1 -0
  43. package/build/dist/Server/Utils/Express.js.map +1 -1
  44. package/build/dist/Server/Utils/OpenAPI.js +24 -0
  45. package/build/dist/Server/Utils/OpenAPI.js.map +1 -1
  46. package/build/dist/Server/Utils/StartServer.js +17 -2
  47. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  48. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +77 -8
  49. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  50. package/build/dist/UI/Components/LogsViewer/components/ColumnSelector.js +115 -0
  51. package/build/dist/UI/Components/LogsViewer/components/ColumnSelector.js.map +1 -0
  52. package/build/dist/UI/Components/LogsViewer/components/LiveLogsToggle.js +3 -3
  53. package/build/dist/UI/Components/LogsViewer/components/LiveLogsToggle.js.map +1 -1
  54. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +2 -2
  55. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
  56. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +379 -0
  57. package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -0
  58. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +27 -13
  59. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  60. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js +3 -3
  61. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js.map +1 -1
  62. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +118 -49
  63. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
  64. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +35 -11
  65. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  66. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js +58 -0
  67. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js.map +1 -0
  68. package/build/dist/UI/Components/LogsViewer/types.js +60 -1
  69. package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
  70. package/package.json +2 -2
@@ -1,5 +1,10 @@
1
1
  import React, { FunctionComponent, ReactElement, useMemo } from "react";
2
- import { FacetData, FacetValue, ActiveFilter } from "../types";
2
+ import {
3
+ FacetData,
4
+ FacetValue,
5
+ ActiveFilter,
6
+ LogsSavedViewOption,
7
+ } from "../types";
3
8
  import FacetSection from "./FacetSection";
4
9
  import Service from "../../../../Models/DatabaseModels/Service";
5
10
  import Dictionary from "../../../../Types/Dictionary";
@@ -14,6 +19,9 @@ export interface LogsFacetSidebarProps {
14
19
  onIncludeFilter: (facetKey: string, value: string) => void;
15
20
  onExcludeFilter: (facetKey: string, value: string) => void;
16
21
  activeFilters?: Array<ActiveFilter> | undefined;
22
+ savedViews?: Array<LogsSavedViewOption> | undefined;
23
+ selectedSavedViewId?: string | null | undefined;
24
+ onSavedViewSelect?: ((viewId: string) => void) | undefined;
17
25
  }
18
26
 
19
27
  const SEVERITY_ORDER: Array<string> = [
@@ -137,6 +145,43 @@ const LogsFacetSidebar: FunctionComponent<LogsFacetSidebarProps> = (
137
145
  )}
138
146
 
139
147
  <div className="flex-1 overflow-y-auto">
148
+ {props.savedViews && props.savedViews.length > 0 && (
149
+ <div className="border-b border-gray-100 px-3 py-3">
150
+ <p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-gray-400">
151
+ Saved Views
152
+ </p>
153
+
154
+ <div className="space-y-1.5">
155
+ {props.savedViews.map((view: LogsSavedViewOption) => {
156
+ const isSelected: boolean =
157
+ view.id === props.selectedSavedViewId;
158
+
159
+ return (
160
+ <button
161
+ key={view.id}
162
+ type="button"
163
+ className={`flex w-full items-center justify-between rounded-md px-2.5 py-2 text-left text-sm transition-colors ${
164
+ isSelected
165
+ ? "bg-indigo-50 text-indigo-700"
166
+ : "text-gray-600 hover:bg-gray-50 hover:text-gray-800"
167
+ }`}
168
+ onClick={() => {
169
+ props.onSavedViewSelect?.(view.id);
170
+ }}
171
+ >
172
+ <span className="truncate">{view.name}</span>
173
+ {view.isDefault && (
174
+ <span className="ml-2 rounded-full bg-amber-100 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
175
+ Default
176
+ </span>
177
+ )}
178
+ </button>
179
+ );
180
+ })}
181
+ </div>
182
+ </div>
183
+ )}
184
+
140
185
  {facetKeys.map((key: string) => {
141
186
  const values: Array<FacetValue> = props.facetData[key] || [];
142
187
 
@@ -25,8 +25,8 @@ const LogsFilterCard: FunctionComponent<LogsFilterCardProps> = (
25
25
  ];
26
26
 
27
27
  return (
28
- <div className="flex items-start gap-3">
29
- <div className="min-w-0 flex-1">
28
+ <div className="flex flex-col gap-3">
29
+ <div>
30
30
  <LogSearchBar
31
31
  value={props.searchQuery}
32
32
  onChange={props.onSearchQueryChange}
@@ -36,7 +36,7 @@ const LogsFilterCard: FunctionComponent<LogsFilterCardProps> = (
36
36
  onFieldValueSelect={props.onFieldValueSelect}
37
37
  />
38
38
  </div>
39
- <div className="flex-none pt-0.5">{props.toolbar}</div>
39
+ <div>{props.toolbar}</div>
40
40
  </div>
41
41
  );
42
42
  };
@@ -10,6 +10,11 @@ import { getSeverityTheme, SeverityTheme } from "./severityTheme";
10
10
  import SortOrder from "../../../../Types/BaseDatabase/SortOrder";
11
11
  import Icon from "../../Icon/Icon";
12
12
  import IconProp from "../../../../Types/Icon/IconProp";
13
+ import {
14
+ getLogsAttributeKeyFromColumnId,
15
+ isLogsAttributeColumnId,
16
+ normalizeLogsTableColumns,
17
+ } from "../types";
13
18
 
14
19
  export interface LogsTableProps {
15
20
  logs: Array<Log>;
@@ -22,6 +27,7 @@ export interface LogsTableProps {
22
27
  sortField?: LogsTableSortField | undefined;
23
28
  sortOrder?: SortOrder | undefined;
24
29
  onSortChange?: (field: LogsTableSortField) => void;
30
+ selectedColumns?: Array<string> | undefined;
25
31
  }
26
32
 
27
33
  export const resolveLogIdentifier: (log: Log, index: number) => string = (
@@ -53,12 +59,39 @@ export const resolveLogIdentifier: (log: Log, index: number) => string = (
53
59
 
54
60
  export type LogsTableSortField = "time" | "severityText";
55
61
 
62
+ const stringifyLogValue: (value: unknown) => string = (
63
+ value: unknown,
64
+ ): string => {
65
+ if (value === undefined || value === null) {
66
+ return "-";
67
+ }
68
+
69
+ if (typeof value === "string") {
70
+ return value || "-";
71
+ }
72
+
73
+ if (typeof value === "number" || typeof value === "boolean") {
74
+ return value.toString();
75
+ }
76
+
77
+ try {
78
+ return JSON.stringify(value);
79
+ } catch {
80
+ return String(value);
81
+ }
82
+ };
83
+
56
84
  const LogsTable: FunctionComponent<LogsTableProps> = (
57
85
  props: LogsTableProps,
58
86
  ): ReactElement => {
59
87
  const showEmptyState: boolean = !props.isLoading && props.logs.length === 0;
60
88
  const activeSortField: LogsTableSortField | undefined = props.sortField;
61
89
  const activeSortOrder: SortOrder = props.sortOrder || SortOrder.Descending;
90
+ const selectedColumns: Array<string> = normalizeLogsTableColumns(
91
+ props.selectedColumns,
92
+ );
93
+ const showTraceColumn: boolean = selectedColumns.includes("traceId");
94
+ const showSpanColumn: boolean = selectedColumns.includes("spanId");
62
95
 
63
96
  const resolveSortIcon: (field: LogsTableSortField) => IconProp = (
64
97
  field: LogsTableSortField,
@@ -83,68 +116,117 @@ const LogsTable: FunctionComponent<LogsTableProps> = (
83
116
  return `${base} text-gray-300`;
84
117
  };
85
118
 
119
+ const getHeaderCell: (columnId: string) => ReactElement = (
120
+ columnId: string,
121
+ ): ReactElement => {
122
+ if (columnId === "time") {
123
+ return (
124
+ <th scope="col" className="px-4 py-2.5" key={columnId}>
125
+ <button
126
+ type="button"
127
+ className={`flex items-center gap-2 text-left font-semibold tracking-wider text-gray-500 transition-colors hover:text-gray-700 focus:outline-none ${
128
+ activeSortField === "time" ? "text-gray-700" : ""
129
+ }`}
130
+ onClick={() => {
131
+ props.onSortChange?.("time");
132
+ }}
133
+ aria-sort={
134
+ activeSortField === "time"
135
+ ? activeSortOrder === SortOrder.Descending
136
+ ? "descending"
137
+ : "ascending"
138
+ : "none"
139
+ }
140
+ >
141
+ <span>Time</span>
142
+ <Icon
143
+ icon={resolveSortIcon("time")}
144
+ className={resolveSortIconClass("time")}
145
+ aria-hidden="true"
146
+ />
147
+ </button>
148
+ </th>
149
+ );
150
+ }
151
+
152
+ if (columnId === "severity") {
153
+ return (
154
+ <th scope="col" className="px-4 py-2.5" key={columnId}>
155
+ <button
156
+ type="button"
157
+ className={`flex items-center gap-2 text-left font-semibold tracking-wider text-gray-500 transition-colors hover:text-gray-700 focus:outline-none ${
158
+ activeSortField === "severityText" ? "text-gray-700" : ""
159
+ }`}
160
+ onClick={() => {
161
+ props.onSortChange?.("severityText");
162
+ }}
163
+ aria-sort={
164
+ activeSortField === "severityText"
165
+ ? activeSortOrder === SortOrder.Descending
166
+ ? "descending"
167
+ : "ascending"
168
+ : "none"
169
+ }
170
+ >
171
+ <span>Severity</span>
172
+ <Icon
173
+ icon={resolveSortIcon("severityText")}
174
+ className={resolveSortIconClass("severityText")}
175
+ aria-hidden="true"
176
+ />
177
+ </button>
178
+ </th>
179
+ );
180
+ }
181
+
182
+ if (columnId === "service") {
183
+ return (
184
+ <th scope="col" className="px-4 py-2.5" key={columnId}>
185
+ Service
186
+ </th>
187
+ );
188
+ }
189
+
190
+ if (columnId === "message") {
191
+ return (
192
+ <th scope="col" className="px-4 py-2.5" key={columnId}>
193
+ Message
194
+ </th>
195
+ );
196
+ }
197
+
198
+ if (columnId === "traceId") {
199
+ return (
200
+ <th scope="col" className="px-4 py-2.5" key={columnId}>
201
+ Trace ID
202
+ </th>
203
+ );
204
+ }
205
+
206
+ if (columnId === "spanId") {
207
+ return (
208
+ <th scope="col" className="px-4 py-2.5" key={columnId}>
209
+ Span ID
210
+ </th>
211
+ );
212
+ }
213
+
214
+ return (
215
+ <th scope="col" className="px-4 py-2.5" key={columnId}>
216
+ {getLogsAttributeKeyFromColumnId(columnId) || columnId}
217
+ </th>
218
+ );
219
+ };
220
+
86
221
  return (
87
222
  <div className="relative">
88
223
  <div className="overflow-x-auto bg-white">
89
224
  <table className="min-w-full">
90
225
  <thead className="bg-gray-50/80">
91
226
  <tr className="text-left text-[11px] font-semibold uppercase tracking-wider text-gray-500">
92
- <th scope="col" className="px-4 py-2.5">
93
- <button
94
- type="button"
95
- className={`flex items-center gap-2 text-left font-semibold tracking-wider text-gray-500 transition-colors hover:text-gray-700 focus:outline-none ${
96
- activeSortField === "time" ? "text-gray-700" : ""
97
- }`}
98
- onClick={() => {
99
- props.onSortChange?.("time");
100
- }}
101
- aria-sort={
102
- activeSortField === "time"
103
- ? activeSortOrder === SortOrder.Descending
104
- ? "descending"
105
- : "ascending"
106
- : "none"
107
- }
108
- >
109
- <span>Time</span>
110
- <Icon
111
- icon={resolveSortIcon("time")}
112
- className={resolveSortIconClass("time")}
113
- aria-hidden="true"
114
- />
115
- </button>
116
- </th>
117
- <th scope="col" className="px-4 py-2.5">
118
- <span>Service</span>
119
- </th>
120
- <th scope="col" className="px-4 py-2.5">
121
- <button
122
- type="button"
123
- className={`flex items-center gap-2 text-left font-semibold tracking-wider text-gray-500 transition-colors hover:text-gray-700 focus:outline-none ${
124
- activeSortField === "severityText" ? "text-gray-700" : ""
125
- }`}
126
- onClick={() => {
127
- props.onSortChange?.("severityText");
128
- }}
129
- aria-sort={
130
- activeSortField === "severityText"
131
- ? activeSortOrder === SortOrder.Descending
132
- ? "descending"
133
- : "ascending"
134
- : "none"
135
- }
136
- >
137
- <span>Severity</span>
138
- <Icon
139
- icon={resolveSortIcon("severityText")}
140
- className={resolveSortIconClass("severityText")}
141
- aria-hidden="true"
142
- />
143
- </button>
144
- </th>
145
- <th scope="col" className="px-4 py-2.5">
146
- Message
147
- </th>
227
+ {selectedColumns.map((columnId: string) => {
228
+ return getHeaderCell(columnId);
229
+ })}
148
230
  </tr>
149
231
  </thead>
150
232
  <tbody className="divide-y divide-gray-100">
@@ -181,59 +263,162 @@ const LogsTable: FunctionComponent<LogsTableProps> = (
181
263
  aria-selected={isSelected}
182
264
  aria-expanded={isSelected}
183
265
  >
184
- <td className="whitespace-nowrap px-4 py-2 text-[13px] font-mono text-gray-600">
185
- {log.time
186
- ? OneUptimeDate.getDateAsUserFriendlyFormattedString(
187
- log.time,
188
- )
189
- : "-"}
190
- </td>
191
- <td className="px-4 py-2">
192
- <div className="flex items-center gap-3 text-sm text-gray-700">
193
- <span
194
- className="h-2.5 w-2.5 flex-none rounded-full shadow-sm"
195
- style={{ backgroundColor: serviceColor }}
196
- aria-hidden="true"
197
- />
198
- <span className="truncate" title={serviceName}>
199
- {serviceName}
200
- </span>
201
- </div>
202
- </td>
203
- <td className="px-4 py-2">
204
- <SeverityBadge severity={log.severityText} />
205
- </td>
206
- <td className="px-4 py-2">
207
- <div className="flex items-start justify-between gap-3">
208
- <div className="flex min-w-0 flex-1 flex-col gap-1">
209
- <p
210
- className="whitespace-pre-wrap break-words text-sm text-gray-800"
211
- title={message}
266
+ {selectedColumns.map((columnId: string) => {
267
+ if (columnId === "time") {
268
+ return (
269
+ <td
270
+ className="whitespace-nowrap px-4 py-2 text-[13px] font-mono text-gray-600"
271
+ key={columnId}
212
272
  >
213
- {message || "-"}
214
- </p>
215
- {(traceId || spanId) && (
216
- <div className="flex flex-wrap gap-3 text-[11px] tracking-wide text-gray-400">
217
- {traceId && <span>Trace: {traceId}</span>}
218
- {spanId && <span>Span: {spanId}</span>}
273
+ {log.time
274
+ ? OneUptimeDate.getDateAsUserFriendlyFormattedString(
275
+ log.time,
276
+ )
277
+ : "-"}
278
+ </td>
279
+ );
280
+ }
281
+
282
+ if (columnId === "service") {
283
+ return (
284
+ <td className="px-4 py-2" key={columnId}>
285
+ <div className="flex items-center gap-3 text-sm text-gray-700">
286
+ <span
287
+ className="h-2.5 w-2.5 flex-none rounded-full shadow-sm"
288
+ style={{ backgroundColor: serviceColor }}
289
+ aria-hidden="true"
290
+ />
291
+ <span className="truncate" title={serviceName}>
292
+ {serviceName}
293
+ </span>
294
+ </div>
295
+ </td>
296
+ );
297
+ }
298
+
299
+ if (columnId === "severity") {
300
+ return (
301
+ <td className="px-4 py-2" key={columnId}>
302
+ <SeverityBadge severity={log.severityText} />
303
+ </td>
304
+ );
305
+ }
306
+
307
+ if (columnId === "message") {
308
+ return (
309
+ <td className="px-4 py-2" key={columnId}>
310
+ <div className="flex items-start justify-between gap-3">
311
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
312
+ <p
313
+ className="whitespace-pre-wrap break-words text-sm text-gray-800"
314
+ title={message}
315
+ >
316
+ {message || "-"}
317
+ </p>
318
+ {((traceId && !showTraceColumn) ||
319
+ (spanId && !showSpanColumn)) && (
320
+ <div className="flex flex-wrap gap-3 text-[11px] tracking-wide text-gray-400">
321
+ {traceId && !showTraceColumn && (
322
+ <span>Trace: {traceId}</span>
323
+ )}
324
+ {spanId && !showSpanColumn && (
325
+ <span>Span: {spanId}</span>
326
+ )}
327
+ </div>
328
+ )}
329
+ </div>
330
+ <CopyTextButton
331
+ textToBeCopied={message}
332
+ size="xs"
333
+ variant="ghost"
334
+ iconOnly={true}
335
+ title="Copy log message"
336
+ className="opacity-0 transition-opacity group-hover:opacity-100"
337
+ />
219
338
  </div>
220
- )}
221
- </div>
222
- <CopyTextButton
223
- textToBeCopied={message}
224
- size="xs"
225
- variant="ghost"
226
- iconOnly={true}
227
- title="Copy log message"
228
- className="opacity-0 group-hover:opacity-100 transition-opacity"
229
- />
230
- </div>
231
- </td>
339
+ </td>
340
+ );
341
+ }
342
+
343
+ if (columnId === "traceId") {
344
+ return (
345
+ <td
346
+ className="max-w-xs px-4 py-2 text-sm text-gray-600"
347
+ key={columnId}
348
+ >
349
+ <span
350
+ className="block truncate font-mono"
351
+ title={traceId}
352
+ >
353
+ {traceId || "-"}
354
+ </span>
355
+ </td>
356
+ );
357
+ }
358
+
359
+ if (columnId === "spanId") {
360
+ return (
361
+ <td
362
+ className="max-w-xs px-4 py-2 text-sm text-gray-600"
363
+ key={columnId}
364
+ >
365
+ <span
366
+ className="block truncate font-mono"
367
+ title={spanId}
368
+ >
369
+ {spanId || "-"}
370
+ </span>
371
+ </td>
372
+ );
373
+ }
374
+
375
+ if (isLogsAttributeColumnId(columnId)) {
376
+ const attributeKey: string | null =
377
+ getLogsAttributeKeyFromColumnId(columnId);
378
+ const attributeValue: unknown =
379
+ attributeKey &&
380
+ typeof log.attributes === "object" &&
381
+ log.attributes
382
+ ? (log.attributes as Record<string, unknown>)[
383
+ attributeKey
384
+ ]
385
+ : undefined;
386
+
387
+ const displayValue: string =
388
+ stringifyLogValue(attributeValue);
389
+
390
+ return (
391
+ <td
392
+ className="max-w-xs px-4 py-2 text-sm text-gray-600"
393
+ key={columnId}
394
+ >
395
+ <span
396
+ className="block truncate"
397
+ title={displayValue}
398
+ >
399
+ {displayValue}
400
+ </span>
401
+ </td>
402
+ );
403
+ }
404
+
405
+ return (
406
+ <td
407
+ className="px-4 py-2 text-sm text-gray-600"
408
+ key={columnId}
409
+ >
410
+ -
411
+ </td>
412
+ );
413
+ })}
232
414
  </tr>
233
415
 
234
416
  {isSelected && props.renderExpandedContent && (
235
417
  <tr className="bg-white">
236
- <td colSpan={4} className="px-6 pb-6 pt-3">
418
+ <td
419
+ colSpan={selectedColumns.length}
420
+ className="px-6 pb-6 pt-3"
421
+ >
237
422
  {props.renderExpandedContent(log)}
238
423
  </td>
239
424
  </tr>
@@ -1,7 +1,14 @@
1
1
  import React, { FunctionComponent, ReactElement } from "react";
2
2
  import LiveLogsToggle from "./LiveLogsToggle";
3
3
  import LogTimeRangePicker from "./LogTimeRangePicker";
4
- import { LiveLogsOptions } from "../types";
4
+ import ColumnSelector from "./ColumnSelector";
5
+ import SavedViewsDropdown from "./SavedViewsDropdown";
6
+ import {
7
+ LiveLogsOptions,
8
+ LogsSavedViewOption,
9
+ LogsTableColumnOption,
10
+ LogsViewMode,
11
+ } from "../types";
5
12
  import RangeStartAndEndDateTime from "../../../../Types/Time/RangeStartAndEndDateTime";
6
13
 
7
14
  export interface LogsViewerToolbarProps {
@@ -12,6 +19,18 @@ export interface LogsViewerToolbarProps {
12
19
  liveOptions?: LiveLogsOptions;
13
20
  timeRange?: RangeStartAndEndDateTime;
14
21
  onTimeRangeChange?: (value: RangeStartAndEndDateTime) => void;
22
+ onCreateSavedView?: (() => void) | undefined;
23
+ savedViews?: Array<LogsSavedViewOption> | undefined;
24
+ selectedSavedViewId?: string | null | undefined;
25
+ onSavedViewSelect?: ((viewId: string) => void) | undefined;
26
+ onEditSavedView?: ((viewId: string) => void) | undefined;
27
+ onDeleteSavedView?: ((viewId: string) => void) | undefined;
28
+ onUpdateCurrentSavedView?: (() => void) | undefined;
29
+ availableColumns?: Array<LogsTableColumnOption> | undefined;
30
+ selectedColumns?: Array<string> | undefined;
31
+ onSelectedColumnsChange?: ((columns: Array<string>) => void) | undefined;
32
+ viewMode?: LogsViewMode | undefined;
33
+ onViewModeChange?: ((mode: LogsViewMode) => void) | undefined;
15
34
  }
16
35
 
17
36
  const LogsViewerToolbar: FunctionComponent<LogsViewerToolbarProps> = (
@@ -26,19 +45,102 @@ const LogsViewerToolbar: FunctionComponent<LogsViewerToolbarProps> = (
26
45
  <div
27
46
  className={`flex items-center justify-between gap-3 ${props.className || ""}`}
28
47
  >
29
- <div className="flex flex-wrap items-center gap-3 text-xs text-gray-500">
30
- <span className="font-medium text-gray-700">
31
- {props.resultCount.toLocaleString()} result
32
- {props.resultCount === 1 ? "" : "s"}
33
- </span>
34
- {hasPaginationSummary && (
35
- <span className="text-gray-400">
36
- Page {currentPage} of {totalPages}
37
- </span>
48
+ {/* Left group: View management + stats */}
49
+ <div className="flex flex-wrap items-center gap-3">
50
+ {props.viewMode && props.onViewModeChange && (
51
+ <div className="inline-flex rounded-md shadow-sm" role="group">
52
+ <button
53
+ type="button"
54
+ className={`inline-flex items-center gap-1.5 rounded-l-md border px-3 py-1.5 text-xs font-medium transition-colors ${
55
+ props.viewMode === "list"
56
+ ? "border-indigo-500 bg-indigo-50 text-indigo-700"
57
+ : "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
58
+ }`}
59
+ onClick={() => {
60
+ props.onViewModeChange!("list");
61
+ }}
62
+ >
63
+ <svg
64
+ className="h-3.5 w-3.5"
65
+ fill="none"
66
+ viewBox="0 0 24 24"
67
+ strokeWidth={1.5}
68
+ stroke="currentColor"
69
+ >
70
+ <path
71
+ strokeLinecap="round"
72
+ strokeLinejoin="round"
73
+ d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z"
74
+ />
75
+ </svg>
76
+ List
77
+ </button>
78
+ <button
79
+ type="button"
80
+ className={`inline-flex items-center gap-1.5 rounded-r-md border px-3 py-1.5 text-xs font-medium transition-colors ${
81
+ props.viewMode === "analytics"
82
+ ? "border-indigo-500 bg-indigo-50 text-indigo-700"
83
+ : "border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
84
+ }`}
85
+ onClick={() => {
86
+ props.onViewModeChange!("analytics");
87
+ }}
88
+ >
89
+ <svg
90
+ className="h-3.5 w-3.5"
91
+ fill="none"
92
+ viewBox="0 0 24 24"
93
+ strokeWidth={1.5}
94
+ stroke="currentColor"
95
+ >
96
+ <path
97
+ strokeLinecap="round"
98
+ strokeLinejoin="round"
99
+ d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z"
100
+ />
101
+ </svg>
102
+ Analytics
103
+ </button>
104
+ </div>
105
+ )}
106
+
107
+ {props.savedViews && props.onSavedViewSelect && (
108
+ <SavedViewsDropdown
109
+ savedViews={props.savedViews}
110
+ selectedSavedViewId={props.selectedSavedViewId}
111
+ onSelect={props.onSavedViewSelect}
112
+ onCreate={props.onCreateSavedView}
113
+ onEdit={props.onEditSavedView}
114
+ onDelete={props.onDeleteSavedView}
115
+ onUpdateCurrent={props.onUpdateCurrentSavedView}
116
+ />
38
117
  )}
118
+
119
+ <div className="flex items-center gap-2 text-xs text-gray-500">
120
+ <span className="font-medium text-gray-700">
121
+ {props.resultCount.toLocaleString()} result
122
+ {props.resultCount === 1 ? "" : "s"}
123
+ </span>
124
+ {hasPaginationSummary && (
125
+ <span className="text-gray-400">
126
+ Page {currentPage} of {totalPages}
127
+ </span>
128
+ )}
129
+ </div>
39
130
  </div>
40
131
 
41
- <div className="flex items-center gap-3">
132
+ {/* Right group: Display controls */}
133
+ <div className="flex flex-wrap items-center justify-end gap-2">
134
+ {props.availableColumns &&
135
+ props.selectedColumns &&
136
+ props.onSelectedColumnsChange && (
137
+ <ColumnSelector
138
+ availableColumns={props.availableColumns}
139
+ selectedColumns={props.selectedColumns}
140
+ onChange={props.onSelectedColumnsChange}
141
+ />
142
+ )}
143
+
42
144
  {props.timeRange && props.onTimeRangeChange && (
43
145
  <LogTimeRangePicker
44
146
  value={props.timeRange}