@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
@@ -11,6 +11,7 @@ import "./Environment";
11
11
  import Express, {
12
12
  ExpressApplication,
13
13
  ExpressJson,
14
+ ExpressRaw,
14
15
  ExpressRequest,
15
16
  ExpressResponse,
16
17
  ExpressStatic,
@@ -114,8 +115,26 @@ app.use((req: ExpressRequest, _res: ExpressResponse, next: NextFunction) => {
114
115
  next();
115
116
  });
116
117
 
118
+ /*
119
+ * Parse protobuf (binary) bodies for OTLP ingestion before JSON/gzip middleware.
120
+ * The .NET OpenTelemetry SDK (and others) send telemetry data as application/x-protobuf.
121
+ * Without this, express.json() skips protobuf requests and req.body remains undefined.
122
+ */
123
+ const protobufBodyParserMiddleware: RequestHandler = ExpressRaw({
124
+ type: ["application/x-protobuf", "application/protobuf"],
125
+ limit: "50mb",
126
+ });
127
+
117
128
  app.use((req: OneUptimeRequest, res: ExpressResponse, next: NextFunction) => {
118
- if (req.headers["content-encoding"] === "gzip") {
129
+ const contentType: string | undefined = req.headers["content-type"];
130
+
131
+ if (
132
+ contentType &&
133
+ (contentType.includes("application/x-protobuf") ||
134
+ contentType.includes("application/protobuf"))
135
+ ) {
136
+ protobufBodyParserMiddleware(req, res, next);
137
+ } else if (req.headers["content-encoding"] === "gzip") {
119
138
  const buffers: any = [];
120
139
 
121
140
  req.on("data", (chunk: any) => {
@@ -42,9 +42,18 @@ import {
42
42
  HistogramBucket,
43
43
  FacetData,
44
44
  ActiveFilter,
45
+ CORE_LOGS_TABLE_COLUMN_OPTIONS,
46
+ DEFAULT_LOGS_TABLE_COLUMNS,
47
+ getLogsAttributeColumnId,
48
+ LogsSavedViewOption,
49
+ LogsTableColumnOption,
50
+ LogsViewMode,
51
+ normalizeLogsTableColumns,
45
52
  } from "./types";
53
+ import LogsAnalyticsView from "./components/LogsAnalyticsView";
46
54
  import { queryStringToFilter } from "../../../Types/Log/LogQueryToFilter";
47
55
  import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
56
+ import TimeRange from "../../../Types/Time/TimeRange";
48
57
 
49
58
  export interface ComponentProps {
50
59
  logs: Array<Log>;
@@ -79,11 +88,29 @@ export interface ComponentProps {
79
88
  onFieldValueSelect?: ((fieldKey: string, value: string) => void) | undefined;
80
89
  timeRange?: RangeStartAndEndDateTime | undefined;
81
90
  onTimeRangeChange?: ((value: RangeStartAndEndDateTime) => void) | undefined;
91
+ selectedColumns?: Array<string> | undefined;
92
+ onSelectedColumnsChange?: ((columns: Array<string>) => void) | undefined;
93
+ savedViews?: Array<LogsSavedViewOption> | undefined;
94
+ selectedSavedViewId?: string | null;
95
+ onSavedViewSelect?: ((viewId: string) => void) | undefined;
96
+ onCreateSavedView?: (() => void) | undefined;
97
+ onEditSavedView?: ((viewId: string) => void) | undefined;
98
+ onDeleteSavedView?: ((viewId: string) => void) | undefined;
99
+ onUpdateCurrentSavedView?: (() => void) | undefined;
100
+ viewMode?: LogsViewMode | undefined;
101
+ onViewModeChange?: ((mode: LogsViewMode) => void) | undefined;
102
+ analyticsServiceIds?: Array<string> | undefined;
103
+ analyticsAppliedFacetFilters?: Map<string, Set<string>> | undefined;
82
104
  }
83
105
 
84
106
  export type LogsSortField = LogsTableSortField;
85
107
  export type { LiveLogsOptions } from "./types";
86
- export type { HistogramBucket, FacetData, ActiveFilter } from "./types";
108
+ export type {
109
+ HistogramBucket,
110
+ FacetData,
111
+ ActiveFilter,
112
+ LogsViewMode,
113
+ } from "./types";
87
114
 
88
115
  const DEFAULT_PAGE_SIZE: number = 100;
89
116
  const PAGE_SIZE_OPTIONS: Array<number> = [100, 250, 500, 1000];
@@ -99,6 +126,32 @@ const severityWeight: Record<string, number> = {
99
126
  trace: 1,
100
127
  };
101
128
 
129
+ function getEmptyMessageWithTimeRange(
130
+ timeRange: RangeStartAndEndDateTime | undefined,
131
+ ): string {
132
+ if (!timeRange) {
133
+ return "Adjust filters or check again later.";
134
+ }
135
+
136
+ if (timeRange.range === TimeRange.CUSTOM && timeRange.startAndEndDate) {
137
+ const startDate: Date = timeRange.startAndEndDate.startValue;
138
+ const endDate: Date = timeRange.startAndEndDate.endValue;
139
+ const fmt: (d: Date) => string = (d: Date): string => {
140
+ return d.toLocaleString("en-US", {
141
+ month: "short",
142
+ day: "numeric",
143
+ hour: "2-digit",
144
+ minute: "2-digit",
145
+ });
146
+ };
147
+
148
+ return `Time range: ${fmt(startDate)} – ${fmt(endDate)}. Try adjusting filters or expanding the time range.`;
149
+ }
150
+
151
+ const rangeLabel: string = timeRange.range.toLowerCase();
152
+ return `Time range: ${rangeLabel}. Try adjusting filters or expanding the time range.`;
153
+ }
154
+
102
155
  const getSeverityWeight: (severity: string | undefined) => number = (
103
156
  severity: string | undefined,
104
157
  ): number => {
@@ -151,6 +204,12 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
151
204
  const [localSortOrder, setLocalSortOrder] = useState<SortOrder>(
152
205
  SortOrder.Descending,
153
206
  );
207
+ const [internalSelectedColumns, setInternalSelectedColumns] = useState<
208
+ Array<string>
209
+ >(DEFAULT_LOGS_TABLE_COLUMNS);
210
+
211
+ const [internalViewMode, setInternalViewMode] =
212
+ useState<LogsViewMode>("list");
154
213
 
155
214
  useEffect(() => {
156
215
  setFilterData(props.filterData);
@@ -174,6 +233,14 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
174
233
  }
175
234
  }, [props.pageSize]);
176
235
 
236
+ useEffect(() => {
237
+ if (props.selectedColumns) {
238
+ setInternalSelectedColumns(
239
+ normalizeLogsTableColumns(props.selectedColumns),
240
+ );
241
+ }
242
+ }, [props.selectedColumns]);
243
+
177
244
  const currentPage: number = props.page ?? internalPage;
178
245
  const pageSize: number = props.pageSize ?? internalPageSize;
179
246
 
@@ -519,6 +586,26 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
519
586
  };
520
587
  }, [props.onFieldValueSelect, serviceMap]);
521
588
 
589
+ const selectedColumns: Array<string> = props.selectedColumns
590
+ ? normalizeLogsTableColumns(props.selectedColumns)
591
+ : internalSelectedColumns;
592
+
593
+ const availableColumns: Array<LogsTableColumnOption> = useMemo(() => {
594
+ const attributeColumns: Array<LogsTableColumnOption> = [...logAttributes]
595
+ .sort((left: string, right: string) => {
596
+ return left.localeCompare(right);
597
+ })
598
+ .map((attributeKey: string): LogsTableColumnOption => {
599
+ return {
600
+ id: getLogsAttributeColumnId(attributeKey),
601
+ label: attributeKey,
602
+ attributeKey,
603
+ };
604
+ });
605
+
606
+ return [...CORE_LOGS_TABLE_COLUMN_OPTIONS, ...attributeColumns];
607
+ }, [logAttributes]);
608
+
522
609
  if (isPageLoading) {
523
610
  return <PageLoader isVisible={true} />;
524
611
  }
@@ -527,10 +614,47 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
527
614
  return <ErrorMessage message={pageError} />;
528
615
  }
529
616
 
617
+ const currentViewMode: LogsViewMode = props.viewMode ?? internalViewMode;
618
+
619
+ const handleViewModeChange: (mode: LogsViewMode) => void = (
620
+ mode: LogsViewMode,
621
+ ): void => {
622
+ if (!props.viewMode) {
623
+ setInternalViewMode(mode);
624
+ }
625
+
626
+ props.onViewModeChange?.(mode);
627
+ };
628
+
530
629
  const toolbarProps: LogsViewerToolbarProps = {
531
630
  resultCount: totalItems,
532
631
  currentPage,
533
632
  totalPages,
633
+ viewMode: currentViewMode,
634
+ onViewModeChange: handleViewModeChange,
635
+ savedViews: props.savedViews,
636
+ selectedSavedViewId: props.selectedSavedViewId,
637
+ onSavedViewSelect: props.onSavedViewSelect,
638
+ onCreateSavedView: props.onCreateSavedView,
639
+ onEditSavedView: props.onEditSavedView,
640
+ onDeleteSavedView: props.onDeleteSavedView,
641
+ onUpdateCurrentSavedView: props.onUpdateCurrentSavedView,
642
+ ...(props.onSelectedColumnsChange || props.selectedColumns
643
+ ? {
644
+ availableColumns,
645
+ selectedColumns,
646
+ onSelectedColumnsChange: (columns: Array<string>) => {
647
+ const nextColumns: Array<string> =
648
+ normalizeLogsTableColumns(columns);
649
+
650
+ if (!props.selectedColumns) {
651
+ setInternalSelectedColumns(nextColumns);
652
+ }
653
+
654
+ props.onSelectedColumnsChange?.(nextColumns);
655
+ },
656
+ }
657
+ : {}),
534
658
  ...(props.liveOptions ? { liveOptions: props.liveOptions } : {}),
535
659
  ...(props.timeRange && props.onTimeRangeChange
536
660
  ? {
@@ -577,73 +701,89 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
577
701
  />
578
702
  )}
579
703
 
580
- {/* Main content: sidebar + table */}
581
- <div className="flex gap-3">
582
- {showSidebar && props.facetData && (
583
- <LogsFacetSidebar
584
- facetData={props.facetData}
585
- isLoading={props.facetLoading || false}
586
- serviceMap={serviceMap}
587
- onIncludeFilter={props.onFacetInclude || (() => {})}
588
- onExcludeFilter={props.onFacetExclude || (() => {})}
589
- activeFilters={props.activeFilters}
590
- />
591
- )}
592
-
593
- <div className="min-w-0 flex-1">
594
- <div className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
595
- {!props.showFilters && (
596
- <div className="border-b border-gray-100 bg-gray-50/50 px-4 py-3">
597
- <LogsViewerToolbar {...toolbarProps} />
598
- </div>
599
- )}
600
-
601
- <LogsTable
602
- logs={displayedLogs}
704
+ {/* Main content: analytics view or sidebar + table */}
705
+ {currentViewMode === "analytics" ? (
706
+ <LogsAnalyticsView
707
+ timeRange={props.timeRange || { range: TimeRange.PAST_ONE_HOUR }}
708
+ serviceIds={props.analyticsServiceIds}
709
+ appliedFacetFilters={props.analyticsAppliedFacetFilters || new Map()}
710
+ logAttributes={logAttributes}
711
+ />
712
+ ) : (
713
+ <div className="flex gap-3">
714
+ {showSidebar && props.facetData && (
715
+ <LogsFacetSidebar
716
+ facetData={props.facetData}
717
+ isLoading={props.facetLoading || false}
603
718
  serviceMap={serviceMap}
604
- isLoading={props.isLoading}
605
- emptyMessage={props.noLogsMessage}
606
- onRowClick={(_log: Log, rowId: string) => {
607
- setSelectedLogId((currentSelected: string | null) => {
608
- if (currentSelected === rowId) {
609
- return null;
610
- }
611
-
612
- return rowId;
613
- });
614
- }}
615
- selectedLogId={selectedLogId}
616
- sortField={sortField}
617
- sortOrder={sortOrder}
618
- onSortChange={handleSortChange}
619
- renderExpandedContent={(log: Log) => {
620
- return (
621
- <LogDetailsPanel
622
- log={log}
623
- serviceMap={serviceMap}
624
- onClose={() => {
625
- setSelectedLogId(null);
626
- }}
627
- getTraceRoute={props.getTraceRoute}
628
- getSpanRoute={props.getSpanRoute}
629
- variant="embedded"
630
- />
631
- );
632
- }}
633
- />
634
-
635
- <LogsPagination
636
- currentPage={currentPage}
637
- totalItems={totalItems}
638
- pageSize={pageSize}
639
- pageSizeOptions={PAGE_SIZE_OPTIONS}
640
- onPageChange={handlePageChange}
641
- onPageSizeChange={handlePageSizeChange}
642
- isDisabled={props.isLoading || totalItems === 0}
719
+ onIncludeFilter={props.onFacetInclude || (() => {})}
720
+ onExcludeFilter={props.onFacetExclude || (() => {})}
721
+ activeFilters={props.activeFilters}
722
+ savedViews={props.savedViews}
723
+ selectedSavedViewId={props.selectedSavedViewId}
724
+ onSavedViewSelect={props.onSavedViewSelect}
643
725
  />
726
+ )}
727
+
728
+ <div className="min-w-0 flex-1">
729
+ <div className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
730
+ {!props.showFilters && (
731
+ <div className="border-b border-gray-100 bg-gray-50/50 px-4 py-3">
732
+ <LogsViewerToolbar {...toolbarProps} />
733
+ </div>
734
+ )}
735
+
736
+ <LogsTable
737
+ logs={displayedLogs}
738
+ serviceMap={serviceMap}
739
+ isLoading={props.isLoading}
740
+ emptyMessage={
741
+ props.noLogsMessage ||
742
+ getEmptyMessageWithTimeRange(props.timeRange)
743
+ }
744
+ onRowClick={(_log: Log, rowId: string) => {
745
+ setSelectedLogId((currentSelected: string | null) => {
746
+ if (currentSelected === rowId) {
747
+ return null;
748
+ }
749
+
750
+ return rowId;
751
+ });
752
+ }}
753
+ selectedLogId={selectedLogId}
754
+ sortField={sortField}
755
+ sortOrder={sortOrder}
756
+ onSortChange={handleSortChange}
757
+ selectedColumns={selectedColumns}
758
+ renderExpandedContent={(log: Log) => {
759
+ return (
760
+ <LogDetailsPanel
761
+ log={log}
762
+ serviceMap={serviceMap}
763
+ onClose={() => {
764
+ setSelectedLogId(null);
765
+ }}
766
+ getTraceRoute={props.getTraceRoute}
767
+ getSpanRoute={props.getSpanRoute}
768
+ variant="embedded"
769
+ />
770
+ );
771
+ }}
772
+ />
773
+
774
+ <LogsPagination
775
+ currentPage={currentPage}
776
+ totalItems={totalItems}
777
+ pageSize={pageSize}
778
+ pageSizeOptions={PAGE_SIZE_OPTIONS}
779
+ onPageChange={handlePageChange}
780
+ onPageSizeChange={handlePageSizeChange}
781
+ isDisabled={props.isLoading || totalItems === 0}
782
+ />
783
+ </div>
644
784
  </div>
645
785
  </div>
646
- </div>
786
+ )}
647
787
  </div>
648
788
  );
649
789
  };
@@ -0,0 +1,270 @@
1
+ import React, {
2
+ ChangeEvent,
3
+ FunctionComponent,
4
+ ReactElement,
5
+ useMemo,
6
+ useState,
7
+ } from "react";
8
+ import {
9
+ DEFAULT_LOGS_TABLE_COLUMNS,
10
+ LogsTableColumnOption,
11
+ normalizeLogsTableColumns,
12
+ } from "../types";
13
+ import useComponentOutsideClick from "../../../Types/UseComponentOutsideClick";
14
+
15
+ export interface ColumnSelectorProps {
16
+ availableColumns: Array<LogsTableColumnOption>;
17
+ selectedColumns: Array<string>;
18
+ onChange: (columns: Array<string>) => void;
19
+ }
20
+
21
+ const triggerButtonClassName: string =
22
+ "inline-flex items-center gap-1.5 rounded-md border border-gray-200 bg-white px-2.5 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-colors hover:border-gray-300 hover:bg-gray-50";
23
+
24
+ const actionButtonClassName: string =
25
+ "rounded-md px-2 py-1 text-xs font-medium text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700";
26
+
27
+ const ColumnSelector: FunctionComponent<ColumnSelectorProps> = (
28
+ props: ColumnSelectorProps,
29
+ ): ReactElement => {
30
+ const { ref, isComponentVisible, setIsComponentVisible } =
31
+ useComponentOutsideClick(false);
32
+ const [searchQuery, setSearchQuery] = useState<string>("");
33
+
34
+ const selectedColumnIds: Array<string> = useMemo(() => {
35
+ return normalizeLogsTableColumns(props.selectedColumns);
36
+ }, [props.selectedColumns]);
37
+
38
+ const availableColumnsById: Map<string, LogsTableColumnOption> =
39
+ useMemo(() => {
40
+ return new Map(
41
+ props.availableColumns.map((column: LogsTableColumnOption) => {
42
+ return [column.id, column] as [string, LogsTableColumnOption];
43
+ }),
44
+ );
45
+ }, [props.availableColumns]);
46
+
47
+ const selectedColumns: Array<LogsTableColumnOption> = useMemo(() => {
48
+ return selectedColumnIds.map((columnId: string): LogsTableColumnOption => {
49
+ return (
50
+ availableColumnsById.get(columnId) || {
51
+ id: columnId,
52
+ label: columnId,
53
+ }
54
+ );
55
+ });
56
+ }, [availableColumnsById, selectedColumnIds]);
57
+
58
+ const availableColumns: Array<LogsTableColumnOption> = useMemo(() => {
59
+ const normalizedSearchQuery: string = searchQuery.trim().toLowerCase();
60
+
61
+ return props.availableColumns.filter((column: LogsTableColumnOption) => {
62
+ if (selectedColumnIds.includes(column.id)) {
63
+ return false;
64
+ }
65
+
66
+ if (!normalizedSearchQuery) {
67
+ return true;
68
+ }
69
+
70
+ return column.label.toLowerCase().includes(normalizedSearchQuery);
71
+ });
72
+ }, [props.availableColumns, searchQuery, selectedColumnIds]);
73
+
74
+ const updateColumns: (columns: Array<string>) => void = (
75
+ columns: Array<string>,
76
+ ): void => {
77
+ props.onChange(normalizeLogsTableColumns(columns));
78
+ };
79
+
80
+ const moveColumn: (columnId: string, direction: -1 | 1) => void = (
81
+ columnId: string,
82
+ direction: -1 | 1,
83
+ ): void => {
84
+ const currentIndex: number = selectedColumnIds.indexOf(columnId);
85
+
86
+ if (currentIndex === -1) {
87
+ return;
88
+ }
89
+
90
+ const nextIndex: number = currentIndex + direction;
91
+
92
+ if (nextIndex < 0 || nextIndex >= selectedColumnIds.length) {
93
+ return;
94
+ }
95
+
96
+ const nextColumns: Array<string> = [...selectedColumnIds];
97
+ const currentColumn: string = nextColumns[currentIndex] as string;
98
+ nextColumns[currentIndex] = nextColumns[nextIndex] as string;
99
+ nextColumns[nextIndex] = currentColumn;
100
+
101
+ updateColumns(nextColumns);
102
+ };
103
+
104
+ const removeColumn: (columnId: string) => void = (columnId: string): void => {
105
+ if (selectedColumnIds.length <= 1) {
106
+ return;
107
+ }
108
+
109
+ updateColumns(
110
+ selectedColumnIds.filter((selectedColumnId: string) => {
111
+ return selectedColumnId !== columnId;
112
+ }),
113
+ );
114
+ };
115
+
116
+ const addColumn: (columnId: string) => void = (columnId: string): void => {
117
+ updateColumns([...selectedColumnIds, columnId]);
118
+ };
119
+
120
+ return (
121
+ <div className="relative" ref={ref}>
122
+ <button
123
+ type="button"
124
+ className={triggerButtonClassName}
125
+ onClick={() => {
126
+ setIsComponentVisible(!isComponentVisible);
127
+ }}
128
+ aria-haspopup="dialog"
129
+ aria-expanded={isComponentVisible}
130
+ >
131
+ <span>Columns</span>
132
+ <span className="text-xs text-gray-400">
133
+ {selectedColumnIds.length}
134
+ </span>
135
+ </button>
136
+
137
+ {isComponentVisible && (
138
+ <div className="absolute right-0 z-20 mt-2 w-96 rounded-lg border border-gray-200 bg-white p-4 shadow-xl">
139
+ <div className="flex items-center justify-between gap-3">
140
+ <div>
141
+ <h3 className="text-sm font-semibold text-gray-900">Columns</h3>
142
+ <p className="text-xs text-gray-500">
143
+ Add, remove, and reorder visible columns.
144
+ </p>
145
+ </div>
146
+
147
+ <button
148
+ type="button"
149
+ className="text-xs font-medium text-gray-500 transition-colors hover:text-gray-700"
150
+ onClick={() => {
151
+ updateColumns(DEFAULT_LOGS_TABLE_COLUMNS);
152
+ }}
153
+ >
154
+ Reset
155
+ </button>
156
+ </div>
157
+
158
+ <div className="mt-4">
159
+ <p className="mb-2 text-[11px] font-semibold uppercase tracking-wider text-gray-400">
160
+ Selected
161
+ </p>
162
+
163
+ <div className="space-y-2">
164
+ {selectedColumns.map(
165
+ (column: LogsTableColumnOption, index: number) => {
166
+ const isFirst: boolean = index === 0;
167
+ const isLast: boolean = index === selectedColumns.length - 1;
168
+
169
+ return (
170
+ <div
171
+ key={column.id}
172
+ className="flex items-center justify-between rounded-md border border-gray-200 px-3 py-2"
173
+ >
174
+ <span className="min-w-0 truncate text-sm text-gray-700">
175
+ {column.label}
176
+ </span>
177
+
178
+ <div className="flex items-center gap-1">
179
+ <button
180
+ type="button"
181
+ className={actionButtonClassName}
182
+ onClick={() => {
183
+ moveColumn(column.id, -1);
184
+ }}
185
+ disabled={isFirst}
186
+ >
187
+ Up
188
+ </button>
189
+ <button
190
+ type="button"
191
+ className={actionButtonClassName}
192
+ onClick={() => {
193
+ moveColumn(column.id, 1);
194
+ }}
195
+ disabled={isLast}
196
+ >
197
+ Down
198
+ </button>
199
+ <button
200
+ type="button"
201
+ className={actionButtonClassName}
202
+ onClick={() => {
203
+ removeColumn(column.id);
204
+ }}
205
+ disabled={selectedColumns.length <= 1}
206
+ >
207
+ Remove
208
+ </button>
209
+ </div>
210
+ </div>
211
+ );
212
+ },
213
+ )}
214
+ </div>
215
+ </div>
216
+
217
+ <div className="mt-4">
218
+ <div className="mb-2 flex items-center justify-between gap-3">
219
+ <p className="text-[11px] font-semibold uppercase tracking-wider text-gray-400">
220
+ Available
221
+ </p>
222
+
223
+ <input
224
+ value={searchQuery}
225
+ onChange={(event: ChangeEvent<HTMLInputElement>) => {
226
+ setSearchQuery(event.target.value);
227
+ }}
228
+ placeholder="Search columns"
229
+ className="w-40 rounded-md border border-gray-200 px-2 py-1 text-xs text-gray-600 focus:border-indigo-300 focus:outline-none focus:ring-2 focus:ring-indigo-100"
230
+ />
231
+ </div>
232
+
233
+ <div className="max-h-72 space-y-2 overflow-y-auto pr-1">
234
+ {availableColumns.length === 0 && (
235
+ <div className="rounded-md border border-dashed border-gray-200 px-3 py-4 text-sm text-gray-500">
236
+ No matching columns available.
237
+ </div>
238
+ )}
239
+
240
+ {availableColumns.map((column: LogsTableColumnOption) => {
241
+ return (
242
+ <div
243
+ key={column.id}
244
+ className="flex items-center justify-between rounded-md border border-gray-100 px-3 py-2"
245
+ >
246
+ <span className="min-w-0 truncate text-sm text-gray-700">
247
+ {column.label}
248
+ </span>
249
+
250
+ <button
251
+ type="button"
252
+ className={actionButtonClassName}
253
+ onClick={() => {
254
+ addColumn(column.id);
255
+ }}
256
+ >
257
+ Add
258
+ </button>
259
+ </div>
260
+ );
261
+ })}
262
+ </div>
263
+ </div>
264
+ </div>
265
+ )}
266
+ </div>
267
+ );
268
+ };
269
+
270
+ export default ColumnSelector;
@@ -9,10 +9,10 @@ const LiveLogsToggle: FunctionComponent<LiveLogsToggleProps> = (
9
9
  const { isLive, onToggle, isDisabled } = props;
10
10
 
11
11
  const baseClasses: string =
12
- "inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-medium transition-all focus:outline-none focus:ring-2 focus:ring-emerald-200";
12
+ "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm transition-all focus:outline-none focus:ring-2 focus:ring-emerald-200";
13
13
  const activeClasses: string = isLive
14
14
  ? "border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100"
15
- : "border-gray-200 bg-white text-gray-600 hover:bg-gray-50";
15
+ : "border-gray-200 bg-white text-gray-700 hover:bg-gray-50";
16
16
  const disabledClasses: string = isDisabled
17
17
  ? "cursor-not-allowed opacity-50"
18
18
  : "cursor-pointer";
@@ -36,7 +36,7 @@ const LiveLogsToggle: FunctionComponent<LiveLogsToggleProps> = (
36
36
  isLive ? "bg-emerald-500 animate-pulse" : "bg-gray-300"
37
37
  }`}
38
38
  />
39
- <span className="font-semibold">Live</span>
39
+ <span>Live</span>
40
40
  </button>
41
41
  );
42
42
 
@@ -119,10 +119,10 @@ const LogTimeRangePicker: FunctionComponent<LogTimeRangePickerProps> = (
119
119
  <div ref={containerRef} className="relative">
120
120
  <button
121
121
  type="button"
122
- className={`flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
122
+ className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm transition-colors ${
123
123
  isOpen
124
124
  ? "border-indigo-300 bg-indigo-50 text-indigo-700"
125
- : "border-gray-200 bg-white text-gray-600 hover:border-gray-300 hover:bg-gray-50"
125
+ : "border-gray-200 bg-white text-gray-700 hover:border-gray-300 hover:bg-gray-50"
126
126
  }`}
127
127
  onClick={() => {
128
128
  setIsOpen(!isOpen);