@oneuptime/common 10.0.29 → 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.
- package/Server/API/TelemetryAPI.ts +146 -0
- package/Server/Services/LogAggregationService.ts +387 -0
- package/Server/Types/Workflow/Components/BaseModel/OnTriggerBaseModel.ts +15 -0
- package/UI/Components/LogsViewer/LogsViewer.tsx +108 -71
- package/UI/Components/LogsViewer/components/LogsAnalyticsView.tsx +699 -0
- package/UI/Components/LogsViewer/components/LogsViewerToolbar.tsx +60 -0
- package/UI/Components/LogsViewer/types.ts +2 -0
- package/build/dist/Server/API/TelemetryAPI.js +88 -0
- package/build/dist/Server/API/TelemetryAPI.js.map +1 -1
- package/build/dist/Server/Services/LogAggregationService.js +249 -0
- package/build/dist/Server/Services/LogAggregationService.js.map +1 -1
- package/build/dist/Server/Types/Workflow/Components/BaseModel/OnTriggerBaseModel.js +15 -0
- package/build/dist/Server/Types/Workflow/Components/BaseModel/OnTriggerBaseModel.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js +14 -4
- package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js +379 -0
- package/build/dist/UI/Components/LogsViewer/components/LogsAnalyticsView.js.map +1 -0
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js +17 -0
- package/build/dist/UI/Components/LogsViewer/components/LogsViewerToolbar.js.map +1 -1
- package/build/dist/UI/Components/LogsViewer/types.js.map +1 -1
- package/package.json +1 -1
|
@@ -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;
|