@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
@@ -0,0 +1,699 @@
1
+ import React, {
2
+ FunctionComponent,
3
+ ReactElement,
4
+ useCallback,
5
+ useEffect,
6
+ useMemo,
7
+ useState,
8
+ } from "react";
9
+ import {
10
+ BarChart,
11
+ Bar,
12
+ XAxis,
13
+ YAxis,
14
+ Tooltip,
15
+ ResponsiveContainer,
16
+ CartesianGrid,
17
+ Legend,
18
+ } from "recharts";
19
+ import RangeStartAndEndDateTime, {
20
+ RangeStartAndEndDateTimeUtil,
21
+ } from "../../../../Types/Time/RangeStartAndEndDateTime";
22
+ import InBetween from "../../../../Types/BaseDatabase/InBetween";
23
+ import API from "../../../Utils/API/API";
24
+ import URL from "../../../../Types/API/URL";
25
+ import HTTPResponse from "../../../../Types/API/HTTPResponse";
26
+ import HTTPErrorResponse from "../../../../Types/API/HTTPErrorResponse";
27
+ import { JSONObject } from "../../../../Types/JSON";
28
+ import { APP_API_URL } from "../../../Config";
29
+ import ModelAPI from "../../../Utils/ModelAPI/ModelAPI";
30
+ import ComponentLoader from "../../ComponentLoader/ComponentLoader";
31
+
32
+ type AnalyticsChartType = "timeseries" | "toplist" | "table";
33
+ type AnalyticsAggregation = "count" | "unique";
34
+
35
+ interface AnalyticsTimeseriesRow {
36
+ time: string;
37
+ count: number;
38
+ groupValues: Record<string, string>;
39
+ }
40
+
41
+ interface AnalyticsTopItem {
42
+ value: string;
43
+ count: number;
44
+ }
45
+
46
+ interface AnalyticsTableRow {
47
+ groupValues: Record<string, string>;
48
+ count: number;
49
+ }
50
+
51
+ export interface LogsAnalyticsViewProps {
52
+ timeRange: RangeStartAndEndDateTime;
53
+ serviceIds?: Array<string> | undefined;
54
+ appliedFacetFilters: Map<string, Set<string>>;
55
+ logAttributes: Array<string>;
56
+ }
57
+
58
+ const CHART_COLORS: Array<string> = [
59
+ "#6366f1", // indigo
60
+ "#f43f5e", // rose
61
+ "#10b981", // emerald
62
+ "#f59e0b", // amber
63
+ "#06b6d4", // cyan
64
+ "#ec4899", // pink
65
+ "#84cc16", // lime
66
+ "#d946ef", // fuchsia
67
+ "#64748b", // slate
68
+ "#ef4444", // red
69
+ ];
70
+
71
+ const DIMENSION_OPTIONS: Array<{ value: string; label: string }> = [
72
+ { value: "severityText", label: "Severity" },
73
+ { value: "serviceId", label: "Service" },
74
+ { value: "traceId", label: "Trace ID" },
75
+ { value: "spanId", label: "Span ID" },
76
+ ];
77
+
78
+ const TOP_LIST_LIMITS: Array<number> = [5, 10, 25, 50];
79
+
80
+ interface PivotedTimeseriesRow {
81
+ time: string;
82
+ [key: string]: number | string;
83
+ }
84
+
85
+ function pivotTimeseriesData(rows: Array<AnalyticsTimeseriesRow>): {
86
+ pivotedData: Array<PivotedTimeseriesRow>;
87
+ seriesKeys: Array<string>;
88
+ } {
89
+ const map: Map<string, PivotedTimeseriesRow> = new Map();
90
+ const seriesKeysSet: Set<string> = new Set();
91
+
92
+ for (const row of rows) {
93
+ let pivotRow: PivotedTimeseriesRow | undefined = map.get(row.time);
94
+
95
+ if (!pivotRow) {
96
+ pivotRow = { time: row.time };
97
+ map.set(row.time, pivotRow);
98
+ }
99
+
100
+ const groupKey: string =
101
+ Object.values(row.groupValues).join(" / ") || "count";
102
+ seriesKeysSet.add(groupKey);
103
+ pivotRow[groupKey] = ((pivotRow[groupKey] as number) || 0) + row.count;
104
+ }
105
+
106
+ return {
107
+ pivotedData: Array.from(map.values()),
108
+ seriesKeys: Array.from(seriesKeysSet),
109
+ };
110
+ }
111
+
112
+ function formatTickTime(time: string): string {
113
+ const date: Date = new Date(time);
114
+
115
+ if (isNaN(date.getTime())) {
116
+ return time;
117
+ }
118
+
119
+ return date.toLocaleTimeString([], {
120
+ hour: "2-digit",
121
+ minute: "2-digit",
122
+ hour12: false,
123
+ });
124
+ }
125
+
126
+ function formatYAxisTick(value: number): string {
127
+ if (value >= 1000000) {
128
+ return `${(value / 1000000).toFixed(1)}M`;
129
+ }
130
+
131
+ if (value >= 1000) {
132
+ return `${(value / 1000).toFixed(value >= 10000 ? 0 : 1)}K`;
133
+ }
134
+
135
+ return value.toString();
136
+ }
137
+
138
+ function computeDefaultBucketSize(startTime: Date, endTime: Date): number {
139
+ const diffMs: number = endTime.getTime() - startTime.getTime();
140
+ const diffMinutes: number = diffMs / (1000 * 60);
141
+
142
+ if (diffMinutes <= 60) {
143
+ return 1;
144
+ }
145
+
146
+ if (diffMinutes <= 360) {
147
+ return 5;
148
+ }
149
+
150
+ if (diffMinutes <= 1440) {
151
+ return 15;
152
+ }
153
+
154
+ if (diffMinutes <= 10080) {
155
+ return 60;
156
+ }
157
+
158
+ if (diffMinutes <= 43200) {
159
+ return 360;
160
+ }
161
+
162
+ return 1440;
163
+ }
164
+
165
+ const LogsAnalyticsView: FunctionComponent<LogsAnalyticsViewProps> = (
166
+ props: LogsAnalyticsViewProps,
167
+ ): ReactElement => {
168
+ const [chartType, setChartType] = useState<AnalyticsChartType>("timeseries");
169
+ const [aggregation, setAggregation] = useState<AnalyticsAggregation>("count");
170
+ const [aggregationField, setAggregationField] = useState<string>("");
171
+ const [groupByFields, setGroupByFields] = useState<Array<string>>([
172
+ "severityText",
173
+ ]);
174
+ const [topListLimit, setTopListLimit] = useState<number>(10);
175
+
176
+ const [isLoading, setIsLoading] = useState<boolean>(false);
177
+ const [timeseriesData, setTimeseriesData] = useState<
178
+ Array<AnalyticsTimeseriesRow>
179
+ >([]);
180
+ const [topListData, setTopListData] = useState<Array<AnalyticsTopItem>>([]);
181
+ const [tableData, setTableData] = useState<Array<AnalyticsTableRow>>([]);
182
+
183
+ const allDimensionOptions: Array<{ value: string; label: string }> =
184
+ useMemo(() => {
185
+ const attributeOptions: Array<{ value: string; label: string }> =
186
+ props.logAttributes
187
+ .filter((attr: string) => {
188
+ return !DIMENSION_OPTIONS.some((opt: { value: string }) => {
189
+ return opt.value === attr;
190
+ });
191
+ })
192
+ .map((attr: string) => {
193
+ return { value: attr, label: attr };
194
+ });
195
+
196
+ return [...DIMENSION_OPTIONS, ...attributeOptions];
197
+ }, [props.logAttributes]);
198
+
199
+ const fetchAnalytics: () => Promise<void> =
200
+ useCallback(async (): Promise<void> => {
201
+ try {
202
+ setIsLoading(true);
203
+
204
+ const dateRange: InBetween<Date> =
205
+ RangeStartAndEndDateTimeUtil.getStartAndEndDate(props.timeRange);
206
+
207
+ const startTime: Date = dateRange.startValue;
208
+ const endTime: Date = dateRange.endValue;
209
+
210
+ const requestData: JSONObject = {
211
+ chartType,
212
+ aggregation,
213
+ startTime: startTime.toISOString(),
214
+ endTime: endTime.toISOString(),
215
+ bucketSizeInMinutes: computeDefaultBucketSize(startTime, endTime),
216
+ } as JSONObject;
217
+
218
+ if (
219
+ groupByFields.length > 0 &&
220
+ groupByFields[0] &&
221
+ groupByFields[0].length > 0
222
+ ) {
223
+ (requestData as Record<string, unknown>)["groupBy"] =
224
+ groupByFields.filter((f: string) => {
225
+ return f.length > 0;
226
+ });
227
+ }
228
+
229
+ if (aggregation === "unique" && aggregationField) {
230
+ (requestData as Record<string, unknown>)["aggregationField"] =
231
+ aggregationField;
232
+ }
233
+
234
+ if (chartType === "toplist" || chartType === "table") {
235
+ (requestData as Record<string, unknown>)["limit"] = topListLimit;
236
+ }
237
+
238
+ if (props.serviceIds) {
239
+ (requestData as Record<string, unknown>)["serviceIds"] =
240
+ props.serviceIds;
241
+ }
242
+
243
+ // Apply facet filters
244
+ const severityValues: Set<string> | undefined =
245
+ props.appliedFacetFilters.get("severityText");
246
+
247
+ if (severityValues && severityValues.size > 0) {
248
+ (requestData as Record<string, unknown>)["severityTexts"] =
249
+ Array.from(severityValues);
250
+ }
251
+
252
+ const serviceFilterValues: Set<string> | undefined =
253
+ props.appliedFacetFilters.get("serviceId");
254
+
255
+ if (serviceFilterValues && serviceFilterValues.size > 0) {
256
+ (requestData as Record<string, unknown>)["serviceIds"] =
257
+ Array.from(serviceFilterValues);
258
+ }
259
+
260
+ const traceFilterValues: Set<string> | undefined =
261
+ props.appliedFacetFilters.get("traceId");
262
+
263
+ if (traceFilterValues && traceFilterValues.size > 0) {
264
+ (requestData as Record<string, unknown>)["traceIds"] =
265
+ Array.from(traceFilterValues);
266
+ }
267
+
268
+ const spanFilterValues: Set<string> | undefined =
269
+ props.appliedFacetFilters.get("spanId");
270
+
271
+ if (spanFilterValues && spanFilterValues.size > 0) {
272
+ (requestData as Record<string, unknown>)["spanIds"] =
273
+ Array.from(spanFilterValues);
274
+ }
275
+
276
+ const response: HTTPResponse<JSONObject> | HTTPErrorResponse =
277
+ await API.post({
278
+ url: URL.fromString(APP_API_URL.toString()).addRoute(
279
+ "/telemetry/logs/analytics",
280
+ ),
281
+ data: requestData,
282
+ headers: {
283
+ ...ModelAPI.getCommonHeaders(),
284
+ },
285
+ });
286
+
287
+ if (response instanceof HTTPErrorResponse) {
288
+ throw response;
289
+ }
290
+
291
+ const data: unknown = response.data["data"] || [];
292
+
293
+ if (chartType === "timeseries") {
294
+ setTimeseriesData(data as Array<AnalyticsTimeseriesRow>);
295
+ } else if (chartType === "toplist") {
296
+ setTopListData(data as Array<AnalyticsTopItem>);
297
+ } else {
298
+ setTableData(data as Array<AnalyticsTableRow>);
299
+ }
300
+ } catch {
301
+ // Silently degrade
302
+ setTimeseriesData([]);
303
+ setTopListData([]);
304
+ setTableData([]);
305
+ } finally {
306
+ setIsLoading(false);
307
+ }
308
+ }, [
309
+ chartType,
310
+ aggregation,
311
+ aggregationField,
312
+ groupByFields,
313
+ topListLimit,
314
+ props.timeRange,
315
+ props.serviceIds,
316
+ props.appliedFacetFilters,
317
+ ]);
318
+
319
+ useEffect(() => {
320
+ void fetchAnalytics();
321
+ }, [fetchAnalytics]);
322
+
323
+ const { pivotedData, seriesKeys } = useMemo(() => {
324
+ return pivotTimeseriesData(timeseriesData);
325
+ }, [timeseriesData]);
326
+
327
+ const renderQueryBuilder: () => ReactElement = (): ReactElement => {
328
+ return (
329
+ <div className="flex flex-wrap items-center gap-3 border-b border-gray-100 bg-gray-50/50 px-4 py-3">
330
+ {/* Chart type */}
331
+ <div className="flex items-center gap-1.5">
332
+ <label className="text-xs font-medium text-gray-500">Chart</label>
333
+ <select
334
+ className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
335
+ value={chartType}
336
+ onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
337
+ setChartType(e.target.value as AnalyticsChartType);
338
+ }}
339
+ >
340
+ <option value="timeseries">Timeseries</option>
341
+ <option value="toplist">Top List</option>
342
+ <option value="table">Table</option>
343
+ </select>
344
+ </div>
345
+
346
+ {/* Aggregation */}
347
+ <div className="flex items-center gap-1.5">
348
+ <label className="text-xs font-medium text-gray-500">Measure</label>
349
+ <select
350
+ className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
351
+ value={aggregation}
352
+ onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
353
+ setAggregation(e.target.value as AnalyticsAggregation);
354
+ }}
355
+ >
356
+ <option value="count">Count</option>
357
+ <option value="unique">Unique Count</option>
358
+ </select>
359
+ </div>
360
+
361
+ {/* Aggregation field for unique count */}
362
+ {aggregation === "unique" && (
363
+ <div className="flex items-center gap-1.5">
364
+ <label className="text-xs font-medium text-gray-500">of</label>
365
+ <select
366
+ className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
367
+ value={aggregationField}
368
+ onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
369
+ setAggregationField(e.target.value);
370
+ }}
371
+ >
372
+ <option value="">Select field...</option>
373
+ {allDimensionOptions.map(
374
+ (opt: { value: string; label: string }) => {
375
+ return (
376
+ <option key={opt.value} value={opt.value}>
377
+ {opt.label}
378
+ </option>
379
+ );
380
+ },
381
+ )}
382
+ </select>
383
+ </div>
384
+ )}
385
+
386
+ {/* Group by */}
387
+ <div className="flex items-center gap-1.5">
388
+ <label className="text-xs font-medium text-gray-500">Group by</label>
389
+ <select
390
+ className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
391
+ value={groupByFields[0] || ""}
392
+ onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
393
+ const val: string = e.target.value;
394
+ setGroupByFields((prev: Array<string>) => {
395
+ const next: Array<string> = [...prev];
396
+ next[0] = val;
397
+ return next.filter((f: string) => {
398
+ return f.length > 0;
399
+ });
400
+ });
401
+ }}
402
+ >
403
+ <option value="">None</option>
404
+ {allDimensionOptions.map(
405
+ (opt: { value: string; label: string }) => {
406
+ return (
407
+ <option key={opt.value} value={opt.value}>
408
+ {opt.label}
409
+ </option>
410
+ );
411
+ },
412
+ )}
413
+ </select>
414
+ </div>
415
+
416
+ {/* Second group by (only if first is set) */}
417
+ {groupByFields[0] && groupByFields[0].length > 0 && (
418
+ <div className="flex items-center gap-1.5">
419
+ <label className="text-xs font-medium text-gray-500">then by</label>
420
+ <select
421
+ className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
422
+ value={groupByFields[1] || ""}
423
+ onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
424
+ const val: string = e.target.value;
425
+ setGroupByFields((prev: Array<string>) => {
426
+ const next: Array<string> = [prev[0] || ""];
427
+
428
+ if (val.length > 0) {
429
+ next.push(val);
430
+ }
431
+
432
+ return next.filter((f: string) => {
433
+ return f.length > 0;
434
+ });
435
+ });
436
+ }}
437
+ >
438
+ <option value="">None</option>
439
+ {allDimensionOptions
440
+ .filter((opt: { value: string }) => {
441
+ return opt.value !== groupByFields[0];
442
+ })
443
+ .map((opt: { value: string; label: string }) => {
444
+ return (
445
+ <option key={opt.value} value={opt.value}>
446
+ {opt.label}
447
+ </option>
448
+ );
449
+ })}
450
+ </select>
451
+ </div>
452
+ )}
453
+
454
+ {/* Limit for top list and table */}
455
+ {(chartType === "toplist" || chartType === "table") && (
456
+ <div className="flex items-center gap-1.5">
457
+ <label className="text-xs font-medium text-gray-500">Limit</label>
458
+ <select
459
+ className="rounded-md border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
460
+ value={topListLimit}
461
+ onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
462
+ setTopListLimit(Number(e.target.value));
463
+ }}
464
+ >
465
+ {TOP_LIST_LIMITS.map((limit: number) => {
466
+ return (
467
+ <option key={limit} value={limit}>
468
+ {limit}
469
+ </option>
470
+ );
471
+ })}
472
+ </select>
473
+ </div>
474
+ )}
475
+ </div>
476
+ );
477
+ };
478
+
479
+ const renderTimeseries: () => ReactElement = (): ReactElement => {
480
+ if (pivotedData.length === 0) {
481
+ return renderEmptyState();
482
+ }
483
+
484
+ return (
485
+ <div className="p-4" style={{ height: 320 }}>
486
+ <ResponsiveContainer width="100%" height="100%">
487
+ <BarChart
488
+ data={pivotedData}
489
+ margin={{ top: 8, right: 16, bottom: 0, left: 0 }}
490
+ barCategoryGap="15%"
491
+ barGap={0}
492
+ >
493
+ <CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
494
+ <XAxis
495
+ dataKey="time"
496
+ tickFormatter={formatTickTime}
497
+ tick={{ fontSize: 11, fill: "#9ca3af" }}
498
+ axisLine={{ stroke: "#e5e7eb" }}
499
+ tickLine={false}
500
+ minTickGap={40}
501
+ interval="preserveStartEnd"
502
+ />
503
+ <YAxis
504
+ tick={{ fontSize: 11, fill: "#9ca3af" }}
505
+ axisLine={false}
506
+ tickLine={false}
507
+ width={56}
508
+ allowDecimals={false}
509
+ tickFormatter={formatYAxisTick}
510
+ />
511
+ <Tooltip
512
+ contentStyle={{
513
+ fontSize: 12,
514
+ borderRadius: 6,
515
+ border: "1px solid #e5e7eb",
516
+ }}
517
+ labelFormatter={(label: string) => {
518
+ const d: Date = new Date(label);
519
+
520
+ if (isNaN(d.getTime())) {
521
+ return label;
522
+ }
523
+
524
+ return d.toLocaleString([], {
525
+ month: "short",
526
+ day: "numeric",
527
+ hour: "2-digit",
528
+ minute: "2-digit",
529
+ hour12: false,
530
+ });
531
+ }}
532
+ />
533
+ <Legend
534
+ wrapperStyle={{ fontSize: 11 }}
535
+ iconType="square"
536
+ iconSize={10}
537
+ />
538
+ {seriesKeys.map((key: string, index: number) => {
539
+ return (
540
+ <Bar
541
+ key={key}
542
+ dataKey={key}
543
+ stackId="group"
544
+ fill={CHART_COLORS[index % CHART_COLORS.length]!}
545
+ radius={
546
+ index === seriesKeys.length - 1
547
+ ? [2, 2, 0, 0]
548
+ : [0, 0, 0, 0]
549
+ }
550
+ isAnimationActive={false}
551
+ maxBarSize={32}
552
+ />
553
+ );
554
+ })}
555
+ </BarChart>
556
+ </ResponsiveContainer>
557
+ </div>
558
+ );
559
+ };
560
+
561
+ const renderTopList: () => ReactElement = (): ReactElement => {
562
+ if (topListData.length === 0) {
563
+ return renderEmptyState();
564
+ }
565
+
566
+ const maxCount: number = Math.max(
567
+ ...topListData.map((item: AnalyticsTopItem) => {
568
+ return item.count;
569
+ }),
570
+ 1,
571
+ );
572
+
573
+ return (
574
+ <div className="p-4">
575
+ <div className="space-y-2">
576
+ {topListData.map((item: AnalyticsTopItem, index: number) => {
577
+ const percentage: number = (item.count / maxCount) * 100;
578
+ return (
579
+ <div key={index} className="flex items-center gap-3">
580
+ <div className="w-40 truncate text-xs font-medium text-gray-700">
581
+ {item.value || "(empty)"}
582
+ </div>
583
+ <div className="flex-1">
584
+ <div className="relative h-6 w-full overflow-hidden rounded bg-gray-100">
585
+ <div
586
+ className="absolute left-0 top-0 h-full rounded transition-all"
587
+ style={{
588
+ width: `${percentage}%`,
589
+ backgroundColor:
590
+ CHART_COLORS[index % CHART_COLORS.length],
591
+ }}
592
+ />
593
+ <div className="absolute right-2 top-0 flex h-full items-center text-xs font-medium text-gray-600">
594
+ {item.count.toLocaleString()}
595
+ </div>
596
+ </div>
597
+ </div>
598
+ </div>
599
+ );
600
+ })}
601
+ </div>
602
+ </div>
603
+ );
604
+ };
605
+
606
+ const renderTable: () => ReactElement = (): ReactElement => {
607
+ if (tableData.length === 0) {
608
+ return renderEmptyState();
609
+ }
610
+
611
+ const groupKeys: Array<string> = Object.keys(
612
+ tableData[0]?.groupValues || {},
613
+ );
614
+
615
+ return (
616
+ <div className="p-4">
617
+ <div className="overflow-hidden rounded-lg border border-gray-200">
618
+ <table className="min-w-full divide-y divide-gray-200">
619
+ <thead className="bg-gray-50">
620
+ <tr>
621
+ {groupKeys.map((key: string) => {
622
+ return (
623
+ <th
624
+ key={key}
625
+ className="px-4 py-2 text-left text-xs font-medium uppercase tracking-wider text-gray-500"
626
+ >
627
+ {key}
628
+ </th>
629
+ );
630
+ })}
631
+ <th className="px-4 py-2 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
632
+ Count
633
+ </th>
634
+ </tr>
635
+ </thead>
636
+ <tbody className="divide-y divide-gray-100 bg-white">
637
+ {tableData.map((row: AnalyticsTableRow, index: number) => {
638
+ return (
639
+ <tr key={index} className="hover:bg-gray-50">
640
+ {groupKeys.map((key: string) => {
641
+ return (
642
+ <td
643
+ key={key}
644
+ className="whitespace-nowrap px-4 py-2 text-xs text-gray-700"
645
+ >
646
+ {row.groupValues[key] || "(empty)"}
647
+ </td>
648
+ );
649
+ })}
650
+ <td className="whitespace-nowrap px-4 py-2 text-right text-xs font-medium text-gray-900">
651
+ {row.count.toLocaleString()}
652
+ </td>
653
+ </tr>
654
+ );
655
+ })}
656
+ </tbody>
657
+ </table>
658
+ </div>
659
+ </div>
660
+ );
661
+ };
662
+
663
+ const renderEmptyState: () => ReactElement = (): ReactElement => {
664
+ return (
665
+ <div className="flex h-64 items-center justify-center text-sm text-gray-400">
666
+ No data available for the selected query.
667
+ </div>
668
+ );
669
+ };
670
+
671
+ const renderChart: () => ReactElement = (): ReactElement => {
672
+ if (isLoading) {
673
+ return (
674
+ <div className="flex h-64 items-center justify-center">
675
+ <ComponentLoader />
676
+ </div>
677
+ );
678
+ }
679
+
680
+ if (chartType === "timeseries") {
681
+ return renderTimeseries();
682
+ }
683
+
684
+ if (chartType === "toplist") {
685
+ return renderTopList();
686
+ }
687
+
688
+ return renderTable();
689
+ };
690
+
691
+ return (
692
+ <div className="overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm">
693
+ {renderQueryBuilder()}
694
+ {renderChart()}
695
+ </div>
696
+ );
697
+ };
698
+
699
+ export default LogsAnalyticsView;