@oneuptime/common 10.0.28 → 10.0.29

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 (58) hide show
  1. package/Models/DatabaseModels/Index.ts +2 -0
  2. package/Models/DatabaseModels/LogSavedView.ts +466 -0
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1772355000000-AddLogSavedView.ts +48 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/1773344537755-MigrationName.ts +91 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +4 -0
  6. package/Server/Services/LogSavedViewService.ts +109 -0
  7. package/Server/Utils/Express.ts +1 -0
  8. package/Server/Utils/OpenAPI.ts +28 -0
  9. package/Server/Utils/StartServer.ts +20 -1
  10. package/UI/Components/LogsViewer/LogsViewer.tsx +104 -1
  11. package/UI/Components/LogsViewer/components/ColumnSelector.tsx +270 -0
  12. package/UI/Components/LogsViewer/components/LiveLogsToggle.tsx +3 -3
  13. package/UI/Components/LogsViewer/components/LogTimeRangePicker.tsx +2 -2
  14. package/UI/Components/LogsViewer/components/LogsFacetSidebar.tsx +46 -1
  15. package/UI/Components/LogsViewer/components/LogsFilterCard.tsx +3 -3
  16. package/UI/Components/LogsViewer/components/LogsTable.tsx +288 -103
  17. package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +53 -11
  18. package/UI/Components/LogsViewer/components/SavedViewsDropdown.tsx +175 -0
  19. package/UI/Components/LogsViewer/types.ts +94 -0
  20. package/build/dist/Models/DatabaseModels/Index.js +2 -0
  21. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  22. package/build/dist/Models/DatabaseModels/LogSavedView.js +496 -0
  23. package/build/dist/Models/DatabaseModels/LogSavedView.js.map +1 -0
  24. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772355000000-AddLogSavedView.js +44 -0
  25. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1772355000000-AddLogSavedView.js.map +1 -0
  26. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773344537755-MigrationName.js +38 -0
  27. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1773344537755-MigrationName.js.map +1 -0
  28. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +4 -0
  29. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  30. package/build/dist/Server/Services/LogSavedViewService.js +82 -0
  31. package/build/dist/Server/Services/LogSavedViewService.js.map +1 -0
  32. package/build/dist/Server/Utils/Express.js +1 -0
  33. package/build/dist/Server/Utils/Express.js.map +1 -1
  34. package/build/dist/Server/Utils/OpenAPI.js +24 -0
  35. package/build/dist/Server/Utils/OpenAPI.js.map +1 -1
  36. package/build/dist/Server/Utils/StartServer.js +17 -2
  37. package/build/dist/Server/Utils/StartServer.js.map +1 -1
  38. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +64 -5
  39. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  40. package/build/dist/UI/Components/LogsViewer/components/ColumnSelector.js +115 -0
  41. package/build/dist/UI/Components/LogsViewer/components/ColumnSelector.js.map +1 -0
  42. package/build/dist/UI/Components/LogsViewer/components/LiveLogsToggle.js +3 -3
  43. package/build/dist/UI/Components/LogsViewer/components/LiveLogsToggle.js.map +1 -1
  44. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js +2 -2
  45. package/build/dist/UI/Components/LogsViewer/components/LogTimeRangePicker.js.map +1 -1
  46. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js +27 -13
  47. package/build/dist/UI/Components/LogsViewer/components/LogsFacetSidebar.js.map +1 -1
  48. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js +3 -3
  49. package/build/dist/UI/Components/LogsViewer/components/LogsFilterCard.js.map +1 -1
  50. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js +118 -49
  51. package/build/dist/UI/Components/LogsViewer/components/LogsTable.js.map +1 -1
  52. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +18 -11
  53. package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
  54. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js +58 -0
  55. package/build/dist/UI/Components/LogsViewer/components/SavedViewsDropdown.js.map +1 -0
  56. package/build/dist/UI/Components/LogsViewer/types.js +60 -1
  57. package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
  58. package/package.json +2 -2
@@ -0,0 +1,109 @@
1
+ import DatabaseService from "./DatabaseService";
2
+ import Model from "../../Models/DatabaseModels/LogSavedView";
3
+ import CreateBy from "../Types/Database/CreateBy";
4
+ import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
5
+ import UpdateBy from "../Types/Database/UpdateBy";
6
+ import ObjectID from "../../Types/ObjectID";
7
+ import QueryHelper from "../Types/Database/QueryHelper";
8
+ import LIMIT_MAX from "../../Types/Database/LimitMax";
9
+
10
+ export class Service extends DatabaseService<Model> {
11
+ public constructor() {
12
+ super(Model);
13
+ }
14
+
15
+ protected override async onBeforeCreate(
16
+ createBy: CreateBy<Model>,
17
+ ): Promise<OnCreate<Model>> {
18
+ if (createBy.data.isDefault === undefined && createBy.data.projectId) {
19
+ const existingDefaultView: Model | null = await this.findOneBy({
20
+ query: {
21
+ projectId: createBy.data.projectId,
22
+ isDefault: true,
23
+ },
24
+ select: {
25
+ _id: true,
26
+ },
27
+ props: {
28
+ isRoot: true,
29
+ },
30
+ });
31
+
32
+ createBy.data.isDefault = !existingDefaultView;
33
+ }
34
+
35
+ if (createBy.data.projectId) {
36
+ await this.unsetOtherDefaultsIfNeeded({
37
+ projectId: createBy.data.projectId,
38
+ isDefault: createBy.data.isDefault || false,
39
+ });
40
+ }
41
+
42
+ return { createBy, carryForward: null };
43
+ }
44
+
45
+ protected override async onBeforeUpdate(
46
+ updateBy: UpdateBy<Model>,
47
+ ): Promise<OnUpdate<Model>> {
48
+ if (updateBy.data.isDefault !== true) {
49
+ return { updateBy, carryForward: null };
50
+ }
51
+
52
+ const itemsToUpdate: Array<Model> = await this.findBy({
53
+ query: updateBy.query,
54
+ select: {
55
+ _id: true,
56
+ projectId: true,
57
+ },
58
+ props: {
59
+ isRoot: true,
60
+ },
61
+ limit: LIMIT_MAX,
62
+ skip: 0,
63
+ });
64
+
65
+ for (const item of itemsToUpdate) {
66
+ if (item.projectId) {
67
+ await this.unsetOtherDefaultsIfNeeded({
68
+ projectId: item.projectId,
69
+ isDefault: true,
70
+ excludeIds: item._id ? [item._id] : [],
71
+ });
72
+ }
73
+ }
74
+
75
+ return { updateBy, carryForward: null };
76
+ }
77
+
78
+ private async unsetOtherDefaultsIfNeeded(data: {
79
+ projectId?: ObjectID;
80
+ isDefault?: boolean;
81
+ excludeIds?: Array<string>;
82
+ }): Promise<void> {
83
+ if (!data.projectId || !data.isDefault) {
84
+ return;
85
+ }
86
+
87
+ await this.updateBy({
88
+ query: {
89
+ projectId: data.projectId,
90
+ isDefault: true,
91
+ ...(data.excludeIds && data.excludeIds.length > 0
92
+ ? {
93
+ _id: QueryHelper.notInOrNull(data.excludeIds),
94
+ }
95
+ : {}),
96
+ },
97
+ data: {
98
+ isDefault: false,
99
+ },
100
+ props: {
101
+ isRoot: true,
102
+ },
103
+ limit: LIMIT_MAX,
104
+ skip: 0,
105
+ });
106
+ }
107
+ }
108
+
109
+ export default new Service();
@@ -21,6 +21,7 @@ export type NextFunction = express.NextFunction;
21
21
  export const ExpressStatic: GenericFunction = express.static;
22
22
  export const ExpressJson: GenericFunction = express.json;
23
23
  export const ExpressUrlEncoded: GenericFunction = express.urlencoded;
24
+ export const ExpressRaw: GenericFunction = express.raw;
24
25
 
25
26
  export type ProbeRequest = {
26
27
  id: ObjectID;
@@ -257,6 +257,20 @@ export default class OpenAPIUtil {
257
257
  query: { $ref: `#/components/schemas/${querySchemaName}` },
258
258
  select: { $ref: `#/components/schemas/${selectSchemaName}` },
259
259
  sort: { $ref: `#/components/schemas/${sortSchemaName}` },
260
+ limit: {
261
+ type: "number",
262
+ description:
263
+ "Maximum number of items to return. Defaults to 10.",
264
+ default: 10,
265
+ minimum: 1,
266
+ },
267
+ skip: {
268
+ type: "number",
269
+ description:
270
+ "Number of items to skip for pagination. Defaults to 0.",
271
+ default: 0,
272
+ minimum: 0,
273
+ },
260
274
  },
261
275
  },
262
276
  },
@@ -891,6 +905,20 @@ export default class OpenAPIUtil {
891
905
  select: { $ref: `#/components/schemas/${selectSchemaName}` },
892
906
  sort: { $ref: `#/components/schemas/${sortSchemaName}` },
893
907
  groupBy: { $ref: `#/components/schemas/${groupBySchemaName}` },
908
+ limit: {
909
+ type: "number",
910
+ description:
911
+ "Maximum number of items to return. Defaults to 10.",
912
+ default: 10,
913
+ minimum: 1,
914
+ },
915
+ skip: {
916
+ type: "number",
917
+ description:
918
+ "Number of items to skip for pagination. Defaults to 0.",
919
+ default: 0,
920
+ minimum: 0,
921
+ },
894
922
  },
895
923
  },
896
924
  },
@@ -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,16 @@ 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
+ normalizeLogsTableColumns,
45
51
  } from "./types";
46
52
  import { queryStringToFilter } from "../../../Types/Log/LogQueryToFilter";
47
53
  import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
54
+ import TimeRange from "../../../Types/Time/TimeRange";
48
55
 
49
56
  export interface ComponentProps {
50
57
  logs: Array<Log>;
@@ -79,6 +86,15 @@ export interface ComponentProps {
79
86
  onFieldValueSelect?: ((fieldKey: string, value: string) => void) | undefined;
80
87
  timeRange?: RangeStartAndEndDateTime | undefined;
81
88
  onTimeRangeChange?: ((value: RangeStartAndEndDateTime) => void) | undefined;
89
+ selectedColumns?: Array<string> | undefined;
90
+ onSelectedColumnsChange?: ((columns: Array<string>) => void) | undefined;
91
+ savedViews?: Array<LogsSavedViewOption> | undefined;
92
+ selectedSavedViewId?: string | null;
93
+ onSavedViewSelect?: ((viewId: string) => void) | undefined;
94
+ onCreateSavedView?: (() => void) | undefined;
95
+ onEditSavedView?: ((viewId: string) => void) | undefined;
96
+ onDeleteSavedView?: ((viewId: string) => void) | undefined;
97
+ onUpdateCurrentSavedView?: (() => void) | undefined;
82
98
  }
83
99
 
84
100
  export type LogsSortField = LogsTableSortField;
@@ -99,6 +115,32 @@ const severityWeight: Record<string, number> = {
99
115
  trace: 1,
100
116
  };
101
117
 
118
+ function getEmptyMessageWithTimeRange(
119
+ timeRange: RangeStartAndEndDateTime | undefined,
120
+ ): string {
121
+ if (!timeRange) {
122
+ return "Adjust filters or check again later.";
123
+ }
124
+
125
+ if (timeRange.range === TimeRange.CUSTOM && timeRange.startAndEndDate) {
126
+ const startDate: Date = timeRange.startAndEndDate.startValue;
127
+ const endDate: Date = timeRange.startAndEndDate.endValue;
128
+ const fmt: (d: Date) => string = (d: Date): string => {
129
+ return d.toLocaleString("en-US", {
130
+ month: "short",
131
+ day: "numeric",
132
+ hour: "2-digit",
133
+ minute: "2-digit",
134
+ });
135
+ };
136
+
137
+ return `Time range: ${fmt(startDate)} – ${fmt(endDate)}. Try adjusting filters or expanding the time range.`;
138
+ }
139
+
140
+ const rangeLabel: string = timeRange.range.toLowerCase();
141
+ return `Time range: ${rangeLabel}. Try adjusting filters or expanding the time range.`;
142
+ }
143
+
102
144
  const getSeverityWeight: (severity: string | undefined) => number = (
103
145
  severity: string | undefined,
104
146
  ): number => {
@@ -151,6 +193,9 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
151
193
  const [localSortOrder, setLocalSortOrder] = useState<SortOrder>(
152
194
  SortOrder.Descending,
153
195
  );
196
+ const [internalSelectedColumns, setInternalSelectedColumns] = useState<
197
+ Array<string>
198
+ >(DEFAULT_LOGS_TABLE_COLUMNS);
154
199
 
155
200
  useEffect(() => {
156
201
  setFilterData(props.filterData);
@@ -174,6 +219,14 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
174
219
  }
175
220
  }, [props.pageSize]);
176
221
 
222
+ useEffect(() => {
223
+ if (props.selectedColumns) {
224
+ setInternalSelectedColumns(
225
+ normalizeLogsTableColumns(props.selectedColumns),
226
+ );
227
+ }
228
+ }, [props.selectedColumns]);
229
+
177
230
  const currentPage: number = props.page ?? internalPage;
178
231
  const pageSize: number = props.pageSize ?? internalPageSize;
179
232
 
@@ -519,6 +572,26 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
519
572
  };
520
573
  }, [props.onFieldValueSelect, serviceMap]);
521
574
 
575
+ const selectedColumns: Array<string> = props.selectedColumns
576
+ ? normalizeLogsTableColumns(props.selectedColumns)
577
+ : internalSelectedColumns;
578
+
579
+ const availableColumns: Array<LogsTableColumnOption> = useMemo(() => {
580
+ const attributeColumns: Array<LogsTableColumnOption> = [...logAttributes]
581
+ .sort((left: string, right: string) => {
582
+ return left.localeCompare(right);
583
+ })
584
+ .map((attributeKey: string): LogsTableColumnOption => {
585
+ return {
586
+ id: getLogsAttributeColumnId(attributeKey),
587
+ label: attributeKey,
588
+ attributeKey,
589
+ };
590
+ });
591
+
592
+ return [...CORE_LOGS_TABLE_COLUMN_OPTIONS, ...attributeColumns];
593
+ }, [logAttributes]);
594
+
522
595
  if (isPageLoading) {
523
596
  return <PageLoader isVisible={true} />;
524
597
  }
@@ -531,6 +604,29 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
531
604
  resultCount: totalItems,
532
605
  currentPage,
533
606
  totalPages,
607
+ savedViews: props.savedViews,
608
+ selectedSavedViewId: props.selectedSavedViewId,
609
+ onSavedViewSelect: props.onSavedViewSelect,
610
+ onCreateSavedView: props.onCreateSavedView,
611
+ onEditSavedView: props.onEditSavedView,
612
+ onDeleteSavedView: props.onDeleteSavedView,
613
+ onUpdateCurrentSavedView: props.onUpdateCurrentSavedView,
614
+ ...(props.onSelectedColumnsChange || props.selectedColumns
615
+ ? {
616
+ availableColumns,
617
+ selectedColumns,
618
+ onSelectedColumnsChange: (columns: Array<string>) => {
619
+ const nextColumns: Array<string> =
620
+ normalizeLogsTableColumns(columns);
621
+
622
+ if (!props.selectedColumns) {
623
+ setInternalSelectedColumns(nextColumns);
624
+ }
625
+
626
+ props.onSelectedColumnsChange?.(nextColumns);
627
+ },
628
+ }
629
+ : {}),
534
630
  ...(props.liveOptions ? { liveOptions: props.liveOptions } : {}),
535
631
  ...(props.timeRange && props.onTimeRangeChange
536
632
  ? {
@@ -587,6 +683,9 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
587
683
  onIncludeFilter={props.onFacetInclude || (() => {})}
588
684
  onExcludeFilter={props.onFacetExclude || (() => {})}
589
685
  activeFilters={props.activeFilters}
686
+ savedViews={props.savedViews}
687
+ selectedSavedViewId={props.selectedSavedViewId}
688
+ onSavedViewSelect={props.onSavedViewSelect}
590
689
  />
591
690
  )}
592
691
 
@@ -602,7 +701,10 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
602
701
  logs={displayedLogs}
603
702
  serviceMap={serviceMap}
604
703
  isLoading={props.isLoading}
605
- emptyMessage={props.noLogsMessage}
704
+ emptyMessage={
705
+ props.noLogsMessage ||
706
+ getEmptyMessageWithTimeRange(props.timeRange)
707
+ }
606
708
  onRowClick={(_log: Log, rowId: string) => {
607
709
  setSelectedLogId((currentSelected: string | null) => {
608
710
  if (currentSelected === rowId) {
@@ -616,6 +718,7 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
616
718
  sortField={sortField}
617
719
  sortOrder={sortOrder}
618
720
  onSortChange={handleSortChange}
721
+ selectedColumns={selectedColumns}
619
722
  renderExpandedContent={(log: Log) => {
620
723
  return (
621
724
  <LogDetailsPanel
@@ -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);