@oneuptime/common 8.0.5440 → 8.0.5466

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 (55) hide show
  1. package/Models/DatabaseModels/StatusPage.ts +80 -0
  2. package/Models/DatabaseModels/TelemetryUsageBilling.ts +1 -1
  3. package/Server/API/StatusPageAPI.ts +138 -52
  4. package/Server/EnvironmentConfig.ts +37 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/1761232578396-MigrationName.ts +29 -0
  6. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  7. package/Server/Services/AnalyticsDatabaseService.ts +71 -11
  8. package/Server/Services/OpenTelemetryIngestService.ts +1 -39
  9. package/Server/Services/StatusPageService.ts +117 -0
  10. package/Server/Services/TelemetryUsageBillingService.ts +268 -15
  11. package/Server/Types/Billing/MeteredPlan/TelemetryMeteredPlan.ts +5 -0
  12. package/Server/Utils/Telemetry/Telemetry.ts +135 -81
  13. package/Server/Utils/VM/VMRunner.ts +3 -4
  14. package/Types/Date.ts +5 -0
  15. package/UI/Components/LogsViewer/LogItem.tsx +12 -4
  16. package/UI/Components/LogsViewer/LogsViewer.tsx +131 -29
  17. package/UI/Components/Markdown.tsx/MarkdownViewer.tsx +2 -2
  18. package/UI/Components/Table/TableRow.tsx +89 -77
  19. package/UI/esbuild-config.js +32 -1
  20. package/build/dist/Models/DatabaseModels/StatusPage.js +82 -0
  21. package/build/dist/Models/DatabaseModels/StatusPage.js.map +1 -1
  22. package/build/dist/Models/DatabaseModels/TelemetryUsageBilling.js +1 -1
  23. package/build/dist/Models/DatabaseModels/TelemetryUsageBilling.js.map +1 -1
  24. package/build/dist/Server/API/StatusPageAPI.js +157 -74
  25. package/build/dist/Server/API/StatusPageAPI.js.map +1 -1
  26. package/build/dist/Server/EnvironmentConfig.js +15 -0
  27. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  28. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1761232578396-MigrationName.js +16 -0
  29. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1761232578396-MigrationName.js.map +1 -0
  30. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  31. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  32. package/build/dist/Server/Services/AnalyticsDatabaseService.js +55 -8
  33. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  34. package/build/dist/Server/Services/OpenTelemetryIngestService.js +0 -30
  35. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  36. package/build/dist/Server/Services/StatusPageService.js +95 -0
  37. package/build/dist/Server/Services/StatusPageService.js.map +1 -1
  38. package/build/dist/Server/Services/TelemetryUsageBillingService.js +211 -8
  39. package/build/dist/Server/Services/TelemetryUsageBillingService.js.map +1 -1
  40. package/build/dist/Server/Types/Billing/MeteredPlan/TelemetryMeteredPlan.js +4 -0
  41. package/build/dist/Server/Types/Billing/MeteredPlan/TelemetryMeteredPlan.js.map +1 -1
  42. package/build/dist/Server/Utils/Telemetry/Telemetry.js +84 -60
  43. package/build/dist/Server/Utils/Telemetry/Telemetry.js.map +1 -1
  44. package/build/dist/Server/Utils/VM/VMRunner.js +2 -2
  45. package/build/dist/Server/Utils/VM/VMRunner.js.map +1 -1
  46. package/build/dist/Types/Date.js +4 -0
  47. package/build/dist/Types/Date.js.map +1 -1
  48. package/build/dist/UI/Components/LogsViewer/LogItem.js +5 -3
  49. package/build/dist/UI/Components/LogsViewer/LogItem.js.map +1 -1
  50. package/build/dist/UI/Components/LogsViewer/LogsViewer.js +73 -22
  51. package/build/dist/UI/Components/LogsViewer/LogsViewer.js.map +1 -1
  52. package/build/dist/UI/Components/Markdown.tsx/MarkdownViewer.js +2 -2
  53. package/build/dist/UI/Components/Table/TableRow.js +18 -6
  54. package/build/dist/UI/Components/Table/TableRow.js.map +1 -1
  55. package/package.json +4 -4
@@ -143,10 +143,10 @@ export default class TelemetryUtil {
143
143
  let { prefixKeysWithString } = data;
144
144
 
145
145
  if (prefixKeysWithString) {
146
- prefixKeysWithString = prefixKeysWithString + ".";
146
+ prefixKeysWithString = `${prefixKeysWithString}.`;
147
147
  }
148
148
 
149
- let finalObj: Dictionary<AttributeType | Array<AttributeType>> = {};
149
+ const finalObj: Dictionary<AttributeType | Array<AttributeType>> = {};
150
150
  const attributes: JSONArray = items;
151
151
 
152
152
  if (!attributes) {
@@ -154,25 +154,41 @@ export default class TelemetryUtil {
154
154
  }
155
155
 
156
156
  for (const attribute of attributes) {
157
- if (attribute["key"] && typeof attribute["key"] === "string") {
158
- const keyWithPrefix: string = `${prefixKeysWithString}${attribute["key"]}`;
159
-
160
- const value:
161
- | AttributeType
162
- | Dictionary<AttributeType>
163
- | Array<AttributeType> = this.getAttributeValues(
164
- keyWithPrefix,
165
- attribute["value"],
166
- );
167
-
168
- if (Array.isArray(value)) {
169
- finalObj = { ...finalObj, [keyWithPrefix]: value };
170
- } else if (typeof value === "object" && value !== null) {
171
- finalObj = { ...finalObj, ...(value as Dictionary<AttributeType>) };
172
- } else {
173
- finalObj[keyWithPrefix] = value;
157
+ if (!attribute["key"] || typeof attribute["key"] !== "string") {
158
+ continue;
159
+ }
160
+
161
+ const keyWithPrefix: string = `${prefixKeysWithString}${attribute["key"]}`;
162
+
163
+ const value:
164
+ | AttributeType
165
+ | Dictionary<AttributeType | Array<AttributeType>>
166
+ | Array<AttributeType>
167
+ | null = this.getAttributeValues(keyWithPrefix, attribute["value"]);
168
+
169
+ if (value === null) {
170
+ finalObj[keyWithPrefix] = null;
171
+ continue;
172
+ }
173
+
174
+ if (Array.isArray(value)) {
175
+ finalObj[keyWithPrefix] = value;
176
+ continue;
177
+ }
178
+
179
+ if (typeof value === "object") {
180
+ for (const [nestedKey, nestedValue] of Object.entries(
181
+ value as Dictionary<AttributeType | Array<AttributeType>>,
182
+ )) {
183
+ finalObj[nestedKey] = nestedValue as
184
+ | AttributeType
185
+ | Array<AttributeType>;
174
186
  }
187
+
188
+ continue;
175
189
  }
190
+
191
+ finalObj[keyWithPrefix] = value as AttributeType;
176
192
  }
177
193
 
178
194
  return finalObj;
@@ -181,73 +197,111 @@ export default class TelemetryUtil {
181
197
  public static getAttributeValues(
182
198
  prefixKeysWithString: string,
183
199
  value: JSONValue,
184
- ): AttributeType | Dictionary<AttributeType> | Array<AttributeType> {
200
+ ):
201
+ | AttributeType
202
+ | Dictionary<AttributeType | Array<AttributeType>>
203
+ | Array<AttributeType>
204
+ | null {
185
205
  let finalObj:
186
- | Dictionary<AttributeType>
206
+ | Dictionary<AttributeType | Array<AttributeType>>
187
207
  | AttributeType
188
- | Array<AttributeType> = null;
189
- value = value as JSONObject;
190
-
191
- if (value["stringValue"]) {
192
- finalObj = value["stringValue"] as string;
193
- } else if (value["intValue"]) {
194
- finalObj = value["intValue"] as number;
195
- } else if (value["doubleValue"]) {
196
- finalObj = value["doubleValue"] as number;
197
- } else if (value["boolValue"]) {
198
- finalObj = value["boolValue"] as boolean;
199
- } else if (
200
- value["arrayValue"] &&
201
- (value["arrayValue"] as JSONObject)["values"]
202
- ) {
203
- const values: JSONArray = (value["arrayValue"] as JSONObject)[
204
- "values"
205
- ] as JSONArray;
206
- finalObj = values.map((v: JSONObject) => {
207
- return this.getAttributeValues(
208
- prefixKeysWithString,
209
- v,
210
- ) as AttributeType;
211
- }) as Array<AttributeType>;
212
- } else if (
213
- value["mapValue"] &&
214
- (value["mapValue"] as JSONObject)["fields"]
215
- ) {
216
- const fields: JSONObject = (value["mapValue"] as JSONObject)?.[
217
- "fields"
218
- ] as JSONObject;
219
-
220
- const flattenedFields: Dictionary<AttributeType> = {};
221
- for (const key in fields) {
222
- const prefixKey: string = `${prefixKeysWithString}.${key}`;
223
- const nestedValue: AttributeType | Dictionary<AttributeType> =
224
- this.getAttributeValues(prefixKey, fields[key]) as AttributeType;
225
- if (typeof nestedValue === "object" && nestedValue !== null) {
226
- for (const nestedKey in nestedValue as Dictionary<AttributeType>) {
227
- flattenedFields[`${prefixKey}.${nestedKey}`] = (
228
- nestedValue as Dictionary<AttributeType>
229
- )[nestedKey] as AttributeType;
208
+ | Array<AttributeType>
209
+ | null = null;
210
+ const jsonValue: JSONObject = value as JSONObject;
211
+
212
+ if (jsonValue && typeof jsonValue === "object") {
213
+ if (Object.prototype.hasOwnProperty.call(jsonValue, "stringValue")) {
214
+ const stringValue: JSONValue = jsonValue["stringValue"];
215
+ finalObj =
216
+ stringValue !== undefined && stringValue !== null
217
+ ? (stringValue as string)
218
+ : "";
219
+ } else if (Object.prototype.hasOwnProperty.call(jsonValue, "intValue")) {
220
+ const intValue: JSONValue = jsonValue["intValue"];
221
+ if (intValue !== undefined && intValue !== null) {
222
+ finalObj = intValue as number;
223
+ }
224
+ } else if (
225
+ Object.prototype.hasOwnProperty.call(jsonValue, "doubleValue")
226
+ ) {
227
+ const doubleValue: JSONValue = jsonValue["doubleValue"];
228
+ if (doubleValue !== undefined && doubleValue !== null) {
229
+ finalObj = doubleValue as number;
230
+ }
231
+ } else if (Object.prototype.hasOwnProperty.call(jsonValue, "boolValue")) {
232
+ finalObj = jsonValue["boolValue"] as boolean;
233
+ } else if (
234
+ jsonValue["arrayValue"] &&
235
+ (jsonValue["arrayValue"] as JSONObject)["values"]
236
+ ) {
237
+ const values: JSONArray = (jsonValue["arrayValue"] as JSONObject)[
238
+ "values"
239
+ ] as JSONArray;
240
+ finalObj = values.map((v: JSONObject) => {
241
+ return this.getAttributeValues(
242
+ prefixKeysWithString,
243
+ v,
244
+ ) as AttributeType;
245
+ }) as Array<AttributeType>;
246
+ } else if (
247
+ jsonValue["mapValue"] &&
248
+ (jsonValue["mapValue"] as JSONObject)["fields"]
249
+ ) {
250
+ const fields: JSONObject = (jsonValue["mapValue"] as JSONObject)[
251
+ "fields"
252
+ ] as JSONObject;
253
+
254
+ const flattenedFields: Dictionary<
255
+ AttributeType | Array<AttributeType>
256
+ > = {};
257
+ for (const key in fields) {
258
+ const nestedPrefix: string = `${prefixKeysWithString}.${key}`;
259
+ const nestedValue:
260
+ | AttributeType
261
+ | Dictionary<AttributeType | Array<AttributeType>>
262
+ | Array<AttributeType>
263
+ | null = this.getAttributeValues(nestedPrefix, fields[key]);
264
+
265
+ if (nestedValue === null) {
266
+ flattenedFields[nestedPrefix] = null;
267
+ continue;
230
268
  }
231
- } else {
232
- flattenedFields[prefixKey] = nestedValue;
269
+
270
+ if (Array.isArray(nestedValue)) {
271
+ flattenedFields[nestedPrefix] = nestedValue;
272
+ continue;
273
+ }
274
+
275
+ if (typeof nestedValue === "object") {
276
+ for (const [nestedKey, nestedEntry] of Object.entries(
277
+ nestedValue as Dictionary<AttributeType | Array<AttributeType>>,
278
+ )) {
279
+ flattenedFields[nestedKey] = nestedEntry as
280
+ | AttributeType
281
+ | Array<AttributeType>;
282
+ }
283
+
284
+ continue;
285
+ }
286
+
287
+ flattenedFields[nestedPrefix] = nestedValue as AttributeType;
233
288
  }
289
+
290
+ finalObj = flattenedFields;
291
+ } else if (
292
+ jsonValue["kvlistValue"] &&
293
+ (jsonValue["kvlistValue"] as JSONObject)["values"]
294
+ ) {
295
+ const values: JSONArray = (jsonValue["kvlistValue"] as JSONObject)[
296
+ "values"
297
+ ] as JSONArray;
298
+ finalObj = this.getAttributes({
299
+ prefixKeysWithString,
300
+ items: values,
301
+ });
302
+ } else if ("nullValue" in jsonValue) {
303
+ finalObj = null;
234
304
  }
235
- finalObj = flattenedFields;
236
- }
237
- // kvlistValue
238
- else if (
239
- value["kvlistValue"] &&
240
- (value["kvlistValue"] as JSONObject)["values"]
241
- ) {
242
- const values: JSONArray = (value["kvlistValue"] as JSONObject)[
243
- "values"
244
- ] as JSONArray;
245
- finalObj = this.getAttributes({
246
- prefixKeysWithString,
247
- items: values,
248
- }) as Dictionary<AttributeType>;
249
- } else if (value["nullValue"]) {
250
- finalObj = null;
251
305
  }
252
306
 
253
307
  return finalObj;
@@ -48,10 +48,9 @@ export default class VMRunner {
48
48
 
49
49
  vm.createContext(sandbox); // Contextify the object.
50
50
 
51
- const script: string =
52
- `(async()=>{
53
- ${code}
54
- })()` || "";
51
+ const script: string = `(async()=>{
52
+ ${code}
53
+ })()`;
55
54
 
56
55
  const returnVal: any = await vm.runInContext(script, sandbox, {
57
56
  timeout: options.timeout || 5000,
package/Types/Date.ts CHANGED
@@ -1453,4 +1453,9 @@ export default class OneUptimeDate {
1453
1453
  date = this.fromString(date);
1454
1454
  return moment(date).format("YYYY-MM-DD HH:mm:ss");
1455
1455
  }
1456
+
1457
+ public static toClickhouseDateTime(date: Date | string): string {
1458
+ const parsedDate: Date = this.fromString(date);
1459
+ return moment(parsedDate).utc().format("YYYY-MM-DD HH:mm:ss");
1460
+ }
1456
1461
  }
@@ -265,7 +265,7 @@ const LogItem: FunctionComponent<ComponentProps> = (
265
265
  if (isCollapsed) {
266
266
  return (
267
267
  <div
268
- className={`group relative text-slate-200 flex items-center gap-2 cursor-pointer hover:bg-slate-800/40 px-2 py-0.5 border border-transparent border-l ${leftBorderColor} rounded-sm transition-colors duration-100`}
268
+ className={`group relative text-slate-200 flex items-center gap-2 cursor-pointer hover:bg-slate-800/40 px-2 py-0.5 border border-transparent border-l ${leftBorderColor} rounded-sm transition-colors duration-100 font-mono`}
269
269
  onClick={toggleCollapsed}
270
270
  role="button"
271
271
  aria-expanded={!isCollapsed}
@@ -311,11 +311,14 @@ const LogItem: FunctionComponent<ComponentProps> = (
311
311
  className={`${bodyColor} font-mono text-[13px] md:text-sm leading-5 tracking-tight subpixel-antialiased flex-1 min-w-0`}
312
312
  >
313
313
  {isBodyInJSON ? (
314
- <div className="truncate" title={logBodyMinified}>
314
+ <div className="truncate font-mono" title={logBodyMinified}>
315
315
  {logBodyMinified}
316
316
  </div>
317
317
  ) : (
318
- <div className="truncate" title={props.log.body?.toString()}>
318
+ <div
319
+ className="truncate font-mono"
320
+ title={props.log.body?.toString()}
321
+ >
319
322
  {props.log.body?.toString()}
320
323
  </div>
321
324
  )}
@@ -339,7 +342,12 @@ const LogItem: FunctionComponent<ComponentProps> = (
339
342
  className={`group relative text-slate-200 bg-slate-950/70 border ${leftBorderColor} border-l border-slate-900 rounded-sm p-2 hover:border-slate-700 transition-colors`}
340
343
  >
341
344
  {/* Header with Service Name and Close Indicator */}
342
- <div className="flex items-center justify-between mb-1 pb-1 border-b border-slate-800/80">
345
+ <div
346
+ className="flex items-center justify-between mb-1 pb-1 border-b border-slate-800/80"
347
+ onClick={() => {
348
+ toggleCollapsed();
349
+ }}
350
+ >
343
351
  {serviceName && (
344
352
  <div
345
353
  className="text-[13px] font-semibold"
@@ -15,9 +15,11 @@ import React, {
15
15
  ReactElement,
16
16
  Ref,
17
17
  useCallback,
18
+ useMemo,
18
19
  } from "react";
19
20
  import Toggle from "../Toggle/Toggle";
20
21
  import Card from "../Card/Card";
22
+ import Icon from "../Icon/Icon";
21
23
  import Button, { ButtonSize, ButtonStyleType } from "../Button/Button";
22
24
  import IconProp from "../../../Types/Icon/IconProp";
23
25
  import ModelAPI from "../../Utils/ModelAPI/ModelAPI";
@@ -66,8 +68,9 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
66
68
  typeof window !== "undefined" ? window.innerHeight : 900,
67
69
  );
68
70
  const [autoScroll, setAutoScroll] = React.useState<boolean>(true);
69
- const [showScrollToBottom, setShowScrollToBottom] =
71
+ const [showScrollToLatest, setShowScrollToLatest] =
70
72
  React.useState<boolean>(false);
73
+ const [isDescending, setIsDescending] = React.useState<boolean>(false);
71
74
  // removed wrapLines toggle for a cleaner toolbar
72
75
  const logsViewerRef: Ref<HTMLDivElement> = React.useRef<HTMLDivElement>(null);
73
76
  const scrollContainerRef: Ref<HTMLDivElement> =
@@ -89,6 +92,14 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
89
92
  Dictionary<TelemetryService>
90
93
  >({});
91
94
 
95
+ const displayLogs: Array<Log> = useMemo(() => {
96
+ if (isDescending) {
97
+ return [...props.logs].reverse();
98
+ }
99
+
100
+ return props.logs;
101
+ }, [props.logs, isDescending]);
102
+
92
103
  const loadTelemetryServices: PromiseVoidFunction =
93
104
  useCallback(async (): Promise<void> => {
94
105
  try {
@@ -178,33 +189,73 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
178
189
  };
179
190
  }, [loadTelemetryServices]);
180
191
 
181
- // Keep scroll to the bottom of the log
192
+ // Keep scroll aligned with the latest log entry
182
193
 
183
- const scrollToBottom: VoidFunction = (): void => {
194
+ const scrollToLatest: VoidFunction = (): void => {
184
195
  const scrollContainer: HTMLDivElement | null = scrollContainerRef.current;
185
196
 
186
- if (scrollContainer) {
187
- scrollContainer.scrollTop = scrollContainer.scrollHeight;
197
+ if (!scrollContainer) {
198
+ return;
199
+ }
200
+
201
+ if (isDescending) {
202
+ scrollContainer.scrollTop = 0;
203
+ return;
188
204
  }
205
+
206
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
207
+ };
208
+
209
+ const applySortDirection: (nextDescending: boolean) => void = (
210
+ nextDescending: boolean,
211
+ ) => {
212
+ setShowScrollToLatest(false);
213
+ setIsDescending((previous: boolean) => {
214
+ if (previous === nextDescending) {
215
+ return previous;
216
+ }
217
+
218
+ // Apply scroll alignment after the DOM reorders log entries.
219
+ setTimeout(() => {
220
+ const scrollContainer: HTMLDivElement | null =
221
+ scrollContainerRef.current;
222
+
223
+ if (!scrollContainer) {
224
+ return;
225
+ }
226
+
227
+ if (nextDescending) {
228
+ scrollContainer.scrollTop = 0;
229
+ return;
230
+ }
231
+
232
+ scrollContainer.scrollTop = scrollContainer.scrollHeight;
233
+ }, 0);
234
+
235
+ return nextDescending;
236
+ });
189
237
  };
190
238
 
191
- const handleScroll: VoidFunction = (): void => {
239
+ const handleScroll: VoidFunction = React.useCallback((): void => {
192
240
  const scrollContainer: HTMLDivElement | null = scrollContainerRef.current;
193
- if (scrollContainer) {
194
- const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
195
- const isNearBottom: boolean =
196
- scrollHeight - scrollTop - clientHeight < 100;
197
- setShowScrollToBottom(!isNearBottom && props.logs.length > 0);
241
+ if (!scrollContainer) {
242
+ return;
198
243
  }
199
- };
244
+
245
+ const { scrollTop, scrollHeight, clientHeight } = scrollContainer;
246
+ const isNearLatest: boolean = isDescending
247
+ ? scrollTop < 100
248
+ : scrollHeight - scrollTop - clientHeight < 100;
249
+ setShowScrollToLatest(!isNearLatest && displayLogs.length > 0);
250
+ }, [isDescending, displayLogs.length]);
200
251
 
201
252
  React.useEffect(() => {
202
253
  if (!autoScroll) {
203
254
  return;
204
255
  }
205
256
 
206
- scrollToBottom();
207
- }, [props.logs]);
257
+ scrollToLatest();
258
+ }, [props.logs, autoScroll, isDescending]);
208
259
 
209
260
  React.useEffect(() => {
210
261
  const scrollContainer: HTMLDivElement | null = scrollContainerRef.current;
@@ -215,7 +266,7 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
215
266
  };
216
267
  }
217
268
  return () => {}; // Return empty cleanup function if no scrollContainer
218
- }, []);
269
+ }, [handleScroll]);
219
270
 
220
271
  if (isPageLoading) {
221
272
  return <PageLoader isVisible={true} />;
@@ -305,12 +356,54 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
305
356
  </div>
306
357
  <span className="hidden sm:block h-4 w-px bg-slate-200" />
307
358
  <span className="text-xs text-slate-500">
308
- {props.logs.length} result
309
- {props.logs.length !== 1 ? "s" : ""}
359
+ {displayLogs.length} result
360
+ {displayLogs.length !== 1 ? "s" : ""}
310
361
  </span>
311
362
  </div>
312
363
 
313
364
  <div className="flex items-center gap-2">
365
+ <div className="inline-flex items-center rounded-full border border-slate-200 bg-white/80 p-1 shadow-sm ring-1 ring-slate-200/60">
366
+ <button
367
+ type="button"
368
+ aria-pressed={isDescending}
369
+ className={`flex items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold tracking-wide transition-all duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ${
370
+ isDescending
371
+ ? "bg-indigo-600 text-white shadow-sm ring-1 ring-indigo-500/40"
372
+ : "text-slate-500 hover:text-indigo-600"
373
+ }`}
374
+ onClick={() => {
375
+ applySortDirection(true);
376
+ }}
377
+ >
378
+ <Icon
379
+ icon={IconProp.BarsArrowDown}
380
+ className={`h-4 w-4 ${
381
+ isDescending ? "text-white/90" : "text-slate-400"
382
+ }`}
383
+ />
384
+ <span>Newest first</span>
385
+ </button>
386
+ <button
387
+ type="button"
388
+ aria-pressed={!isDescending}
389
+ className={`flex items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold tracking-wide transition-all duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-500 ${
390
+ !isDescending
391
+ ? "bg-indigo-600 text-white shadow-sm ring-1 ring-indigo-500/40"
392
+ : "text-slate-500 hover:text-indigo-600"
393
+ }`}
394
+ onClick={() => {
395
+ applySortDirection(false);
396
+ }}
397
+ >
398
+ <Icon
399
+ icon={IconProp.BarsArrowUp}
400
+ className={`h-4 w-4 ${
401
+ !isDescending ? "text-white/90" : "text-slate-400"
402
+ }`}
403
+ />
404
+ <span>Oldest first</span>
405
+ </button>
406
+ </div>
314
407
  <Button
315
408
  title="Apply Filters"
316
409
  icon={IconProp.Search}
@@ -342,7 +435,7 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
342
435
  onScroll={handleScroll}
343
436
  >
344
437
  <ul role="list" className="divide-y divide-slate-800">
345
- {props.logs.map((log: Log, i: number) => {
438
+ {displayLogs.map((log: Log, i: number) => {
346
439
  const traceRouteProps: OptionalTraceRouteProps =
347
440
  props.getTraceRoute
348
441
  ? { getTraceRoute: props.getTraceRoute }
@@ -364,7 +457,7 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
364
457
  })}
365
458
  </ul>
366
459
 
367
- {props.logs.length === 0 && (
460
+ {displayLogs.length === 0 && (
368
461
  <div className="flex items-center justify-center h-full px-4">
369
462
  <div className="text-center">
370
463
  <h3 className="text-sm font-medium text-slate-300 mb-1">
@@ -380,12 +473,12 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
380
473
  </div>
381
474
  </div>
382
475
 
383
- {/* Floating Scroll to Bottom Button */}
384
- {showScrollToBottom && (
476
+ {/* Floating Scroll to Latest Button */}
477
+ {showScrollToLatest && (
385
478
  <button
386
- onClick={scrollToBottom}
479
+ onClick={scrollToLatest}
387
480
  className="absolute bottom-3 right-3 bg-slate-700 hover:bg-slate-600 text-white p-2 rounded-md shadow transition-all"
388
- title="Scroll to bottom"
481
+ title={isDescending ? "Scroll to top" : "Scroll to bottom"}
389
482
  >
390
483
  <svg
391
484
  className="w-5 h-5"
@@ -393,12 +486,21 @@ const LogsViewer: FunctionComponent<ComponentProps> = (
393
486
  stroke="currentColor"
394
487
  viewBox="0 0 24 24"
395
488
  >
396
- <path
397
- strokeLinecap="round"
398
- strokeLinejoin="round"
399
- strokeWidth={2}
400
- d="M19 14l-7 7m0 0l-7-7m7 7V3"
401
- />
489
+ {isDescending ? (
490
+ <path
491
+ strokeLinecap="round"
492
+ strokeLinejoin="round"
493
+ strokeWidth={2}
494
+ d="M5 14l7-7 7 7m-7-7v18"
495
+ />
496
+ ) : (
497
+ <path
498
+ strokeLinecap="round"
499
+ strokeLinejoin="round"
500
+ strokeWidth={2}
501
+ d="M19 10l-7 7-7-7m7 7V3"
502
+ />
503
+ )}
402
504
  </svg>
403
505
  </button>
404
506
  )}
@@ -98,7 +98,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
98
98
 
99
99
  const baseClass: string = isSyntaxHighlighter
100
100
  ? "mt-4 mb-4 rounded-lg overflow-hidden"
101
- : "bg-gray-900 text-gray-100 mt-4 mb-4 p-4 rounded-lg text-sm overflow-x-auto border border-gray-700";
101
+ : "bg-gray-900 text-gray-100 mt-4 mb-4 p-2 rounded-lg text-sm overflow-x-auto border border-gray-700";
102
102
 
103
103
  return (
104
104
  <pre className={baseClass} {...rest}>
@@ -201,7 +201,7 @@ const MarkdownViewer: FunctionComponent<ComponentProps> = (
201
201
  children={content}
202
202
  language={match[1]}
203
203
  style={a11yDark}
204
- className="rounded-lg mt-4 mb-4 !bg-gray-900 !p-4 text-sm"
204
+ className="rounded-lg mt-4 mb-4 !bg-gray-900 !p-2 text-sm"
205
205
  codeTagProps={{ className: "font-mono" }}
206
206
  />
207
207
  ) : (