@oneuptime/common 11.3.2 → 11.3.4

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 (59) hide show
  1. package/Server/Infrastructure/ClickhouseConfig.ts +21 -0
  2. package/Server/Infrastructure/ClickhouseDatabase.ts +12 -0
  3. package/Server/Services/AnalyticsDatabaseService.ts +79 -4
  4. package/Server/Services/TraceAggregationService.ts +16 -3
  5. package/Tests/Server/Services/TraceAggregationService.test.ts +10 -0
  6. package/Tests/UI/Components/Pill.test.tsx +3 -1
  7. package/Types/Dashboard/DashboardComponentType.ts +1 -0
  8. package/Types/Dashboard/DashboardComponents/DashboardTraceTableComponent.ts +39 -0
  9. package/UI/Components/Date/RangeStartAndEndDateEdit.tsx +228 -44
  10. package/UI/Components/Date/RangeStartAndEndDateView.tsx +73 -21
  11. package/UI/Components/EditionLabel/EditionLabel.tsx +6 -1
  12. package/UI/Components/Footer/Footer.tsx +1 -1
  13. package/UI/Components/GanttChart/ChartContainer.tsx +8 -1
  14. package/UI/Components/Icon/Icon.tsx +13 -1
  15. package/UI/Components/Image/Image.tsx +6 -1
  16. package/UI/Components/Link/Link.tsx +22 -0
  17. package/UI/Components/MasterPage/MasterPage.tsx +30 -1
  18. package/UI/Components/Page/Page.tsx +29 -2
  19. package/UI/Components/Pagination/Pagination.tsx +110 -180
  20. package/Utils/Dashboard/Components/DashboardTraceTableComponent.ts +65 -0
  21. package/Utils/Dashboard/Components/Index.ts +7 -0
  22. package/build/dist/Server/Infrastructure/ClickhouseConfig.js +17 -0
  23. package/build/dist/Server/Infrastructure/ClickhouseConfig.js.map +1 -1
  24. package/build/dist/Server/Infrastructure/ClickhouseDatabase.js +10 -1
  25. package/build/dist/Server/Infrastructure/ClickhouseDatabase.js.map +1 -1
  26. package/build/dist/Server/Services/AnalyticsDatabaseService.js +59 -8
  27. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  28. package/build/dist/Server/Services/TraceAggregationService.js +12 -3
  29. package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
  30. package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
  31. package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
  32. package/build/dist/Types/Dashboard/DashboardComponents/DashboardTraceTableComponent.js +2 -0
  33. package/build/dist/Types/Dashboard/DashboardComponents/DashboardTraceTableComponent.js.map +1 -0
  34. package/build/dist/UI/Components/Date/RangeStartAndEndDateEdit.js +116 -26
  35. package/build/dist/UI/Components/Date/RangeStartAndEndDateEdit.js.map +1 -1
  36. package/build/dist/UI/Components/Date/RangeStartAndEndDateView.js +42 -16
  37. package/build/dist/UI/Components/Date/RangeStartAndEndDateView.js.map +1 -1
  38. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +1 -1
  39. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  40. package/build/dist/UI/Components/Footer/Footer.js +1 -1
  41. package/build/dist/UI/Components/GanttChart/ChartContainer.js +9 -1
  42. package/build/dist/UI/Components/GanttChart/ChartContainer.js.map +1 -1
  43. package/build/dist/UI/Components/Icon/Icon.js +2 -2
  44. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  45. package/build/dist/UI/Components/Image/Image.js +8 -1
  46. package/build/dist/UI/Components/Image/Image.js.map +1 -1
  47. package/build/dist/UI/Components/Link/Link.js +9 -0
  48. package/build/dist/UI/Components/Link/Link.js.map +1 -1
  49. package/build/dist/UI/Components/MasterPage/MasterPage.js +2 -1
  50. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  51. package/build/dist/UI/Components/Page/Page.js +24 -1
  52. package/build/dist/UI/Components/Page/Page.js.map +1 -1
  53. package/build/dist/UI/Components/Pagination/Pagination.js +44 -94
  54. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  55. package/build/dist/Utils/Dashboard/Components/DashboardTraceTableComponent.js +51 -0
  56. package/build/dist/Utils/Dashboard/Components/DashboardTraceTableComponent.js.map +1 -0
  57. package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
  58. package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
  59. package/package.json +1 -1
@@ -99,5 +99,26 @@ export const ingestDataSourceOptions: ClickHouseClientConfigOptions = {
99
99
  max_open_connections: MaxClickhouseIngestConnections,
100
100
  };
101
101
 
102
+ /*
103
+ * Dedicated pool for schema sync + data migrations. The 58s request_timeout
104
+ * above is a socket *idle* timer (see the note on request_timeout) sized for
105
+ * dashboard reads sitting behind nginx's 60s proxy_read_timeout. Migrations
106
+ * do NOT go through nginx (the app connects straight to clickhouse:8123), and
107
+ * a single migration statement — an ON CLUSTER DDL, an MV / projection
108
+ * rebuild, or a type/codec-rewrite MODIFY COLUMN on a multi-billion-row
109
+ * telemetry table — can legitimately stream zero bytes for many minutes,
110
+ * which the 58s idle timer would destroy mid-flight ("Timeout error.") and
111
+ * crash the boot process. Give migrations a much higher idle ceiling. It is
112
+ * finite (not 0) on purpose: a genuinely dead connection / network black hole
113
+ * must still fail eventually rather than hang forever. Long statements should
114
+ * additionally carry send_progress_in_http_headers (see MigrationExecuteOptions
115
+ * in AnalyticsDatabaseService) so the socket stays non-idle, and a server-side
116
+ * SETTINGS max_execution_time so ClickHouse remains the authoritative cap.
117
+ */
118
+ export const migrationDataSourceOptions: ClickHouseClientConfigOptions = {
119
+ ...options,
120
+ request_timeout: 30 * 60 * 1000, // 30 minutes
121
+ };
122
+
102
123
  export const testDataSourceOptions: ClickHouseClientConfigOptions =
103
124
  dataSourceOptions;
@@ -3,6 +3,7 @@ import {
3
3
  ClickHouseClientConfigOptions,
4
4
  dataSourceOptions,
5
5
  ingestDataSourceOptions,
6
+ migrationDataSourceOptions,
6
7
  testDataSourceOptions,
7
8
  } from "./ClickhouseConfig";
8
9
  import { PingResult, createClient, ClickHouseClient } from "@clickhouse/client";
@@ -200,3 +201,14 @@ export const ClickhouseAppInstance: ClickhouseDatabase = new ClickhouseDatabase(
200
201
  */
201
202
  export const ClickhouseIngestInstance: ClickhouseDatabase =
202
203
  new ClickhouseDatabase(ingestDataSourceOptions);
204
+
205
+ /*
206
+ * Separate pool for schema sync + data migrations. Identical to the App pool
207
+ * except for a much higher request_timeout (see migrationDataSourceOptions):
208
+ * the App pool's 58s socket-idle timer is correct for dashboard reads but
209
+ * would destroy a long-running migration DDL/mutation mid-flight and crash
210
+ * boot. Schema sync and migrations route through this instance via
211
+ * MigrationExecuteOptions (AnalyticsDatabaseService).
212
+ */
213
+ export const ClickhouseMigrationInstance: ClickhouseDatabase =
214
+ new ClickhouseDatabase(migrationDataSourceOptions);
@@ -3,6 +3,7 @@ import ClickhouseDatabase, {
3
3
  ClickhouseAppInstance,
4
4
  ClickhouseClient,
5
5
  ClickhouseIngestInstance,
6
+ ClickhouseMigrationInstance,
6
7
  } from "../Infrastructure/ClickhouseDatabase";
7
8
  import ClusterKeyAuthorization from "../Middleware/ClusterKeyAuthorization";
8
9
  import CountBy from "../Types/AnalyticsDatabase/CountBy";
@@ -96,8 +97,41 @@ export type { ClickHouseSettings } from "@clickhouse/client";
96
97
  export interface ClickhouseExecuteOptions {
97
98
  clickhouseSettings?: ClickHouseSettings | undefined;
98
99
  queryId?: string | undefined;
100
+ /**
101
+ * Route this statement through the dedicated migration pool
102
+ * (ClickhouseMigrationInstance) instead of the App pool. The migration pool
103
+ * has a much higher `request_timeout` (socket-idle ceiling) so a long DDL /
104
+ * mutation / INSERT...SELECT is not destroyed at the App pool's 58s. Schema
105
+ * sync and data migrations set this (via MigrationExecuteOptions); the read /
106
+ * write hot path leaves it unset and keeps the 58s App pool.
107
+ */
108
+ useMigrationConnection?: boolean | undefined;
99
109
  }
100
110
 
111
+ /**
112
+ * Standard options for every schema-sync / data-migration statement. Two
113
+ * layers of protection against the socket-idle `request_timeout` killing a
114
+ * long migration:
115
+ * 1. `useMigrationConnection` routes through ClickhouseMigrationInstance
116
+ * (30-minute idle ceiling) — reliable even for pure-DDL / ON CLUSTER
117
+ * coordination that streams no bytes at all.
118
+ * 2. `send_progress_in_http_headers` makes the server emit periodic
119
+ * X-ClickHouse-Progress header lines for data-rewriting statements
120
+ * (MODIFY COLUMN rewrites, MATERIALIZE, INSERT...SELECT), keeping the
121
+ * socket non-idle so the request completes instead of timing out.
122
+ * Pass this as the second arg to `execute` / `executeQuery` from migration
123
+ * code. The schema-mutating helpers on this service default to it already.
124
+ */
125
+ export const MigrationExecuteOptions: ClickhouseExecuteOptions = {
126
+ useMigrationConnection: true,
127
+ clickhouseSettings: {
128
+ send_progress_in_http_headers: 1,
129
+ // Emit a progress header every 10s — well under the App pool's 58s and the
130
+ // migration pool's 30-minute idle ceiling.
131
+ http_headers_progress_interval_ms: "10000",
132
+ },
133
+ };
134
+
101
135
  /**
102
136
  * Ambient context that makes ClickHouse inserts idempotent across queue
103
137
  * retries. The telemetry queue worker wraps each job in
@@ -140,9 +174,11 @@ export default class AnalyticsDatabaseService<
140
174
  public modelType!: { new (): TBaseModel };
141
175
  public database!: ClickhouseDatabase;
142
176
  public ingestDatabase!: ClickhouseDatabase;
177
+ public migrationDatabase!: ClickhouseDatabase;
143
178
  public model!: TBaseModel;
144
179
  public databaseClient!: ClickhouseClient | null;
145
180
  public ingestDatabaseClient!: ClickhouseClient | null;
181
+ public migrationDatabaseClient!: ClickhouseClient | null;
146
182
  public statementGenerator!: StatementGenerator<TBaseModel>;
147
183
 
148
184
  public constructor(data: {
@@ -165,8 +201,16 @@ export default class AnalyticsDatabaseService<
165
201
  this.ingestDatabase = ClickhouseIngestInstance;
166
202
  }
167
203
 
204
+ /*
205
+ * Migrations route through their own pool (higher socket-idle timeout) via
206
+ * MigrationExecuteOptions. In tests `data.database` is the in-test instance,
207
+ * so reuse it there to keep tests on a single mocked client.
208
+ */
209
+ this.migrationDatabase = data.database || ClickhouseMigrationInstance;
210
+
168
211
  this.databaseClient = this.database.getDataSource();
169
212
  this.ingestDatabaseClient = this.ingestDatabase.getDataSource();
213
+ this.migrationDatabaseClient = this.migrationDatabase.getDataSource();
170
214
 
171
215
  this.statementGenerator = new StatementGenerator<TBaseModel>({
172
216
  modelType: this.modelType,
@@ -443,13 +487,16 @@ export default class AnalyticsDatabaseService<
443
487
  ): Promise<void> {
444
488
  const statement: Statement =
445
489
  this.statementGenerator.toAddColumnStatement(column);
446
- await this.execute(statement);
490
+ // Schema-sync / migration-only path: route through the migration pool so a
491
+ // column add that backfills a DEFAULT/MATERIALIZED expression on a large
492
+ // table is not destroyed at the App pool's 58s socket-idle timeout.
493
+ await this.execute(statement, MigrationExecuteOptions);
447
494
 
448
495
  // Add skip index separately (ClickHouse requires ADD INDEX as a separate ALTER statement)
449
496
  const indexStatement: Statement | null =
450
497
  this.statementGenerator.toAddSkipIndexStatement(column);
451
498
  if (indexStatement) {
452
- await this.execute(indexStatement);
499
+ await this.execute(indexStatement, MigrationExecuteOptions);
453
500
  }
454
501
  }
455
502
 
@@ -467,11 +514,13 @@ export default class AnalyticsDatabaseService<
467
514
  if (column?.skipIndex) {
468
515
  await this.execute(
469
516
  this.statementGenerator.toDropSkipIndexStatement(column.skipIndex.name),
517
+ MigrationExecuteOptions,
470
518
  );
471
519
  }
472
520
 
473
521
  await this.execute(
474
522
  this.statementGenerator.toDropColumnStatement(columnName),
523
+ MigrationExecuteOptions,
475
524
  );
476
525
  }
477
526
 
@@ -552,6 +601,7 @@ export default class AnalyticsDatabaseService<
552
601
 
553
602
  await this.execute(
554
603
  `ALTER TABLE ${tableName} MODIFY COLUMN ${data.columnName} ${data.columnType} CODEC(${data.codec}) SETTINGS mutations_sync=0`,
604
+ MigrationExecuteOptions,
555
605
  );
556
606
  logger.info(
557
607
  `Applied ${data.codec} codec to ${tableName}.${data.columnName} (async)`,
@@ -1471,6 +1521,8 @@ export default class AnalyticsDatabaseService<
1471
1521
  this.databaseClient = this.database.getDataSource();
1472
1522
  this.ingestDatabase = ClickhouseIngestInstance;
1473
1523
  this.ingestDatabaseClient = this.ingestDatabase.getDataSource();
1524
+ this.migrationDatabase = ClickhouseMigrationInstance;
1525
+ this.migrationDatabaseClient = this.migrationDatabase.getDataSource();
1474
1526
  }
1475
1527
 
1476
1528
  @CaptureSpan()
@@ -1478,7 +1530,9 @@ export default class AnalyticsDatabaseService<
1478
1530
  statement: Statement | string,
1479
1531
  options?: ClickhouseExecuteOptions,
1480
1532
  ): Promise<ExecResult<Stream>> {
1481
- const client: ClickhouseClient = this.getDatabaseClient();
1533
+ const client: ClickhouseClient = options?.useMigrationConnection
1534
+ ? this.getMigrationClient()
1535
+ : this.getDatabaseClient();
1482
1536
 
1483
1537
  const query: string =
1484
1538
  statement instanceof Statement ? statement.query : statement;
@@ -1500,7 +1554,9 @@ export default class AnalyticsDatabaseService<
1500
1554
  statement: Statement | string,
1501
1555
  options?: ClickhouseExecuteOptions,
1502
1556
  ): Promise<ResultSet<"JSON">> {
1503
- const client: ClickhouseClient = this.getDatabaseClient();
1557
+ const client: ClickhouseClient = options?.useMigrationConnection
1558
+ ? this.getMigrationClient()
1559
+ : this.getDatabaseClient();
1504
1560
 
1505
1561
  const query: string =
1506
1562
  statement instanceof Statement ? statement.query : statement;
@@ -1560,6 +1616,25 @@ export default class AnalyticsDatabaseService<
1560
1616
  return this.ingestDatabaseClient;
1561
1617
  }
1562
1618
 
1619
+ private getMigrationClient(): ClickhouseClient {
1620
+ if (!this.migrationDatabase) {
1621
+ this.useDefaultDatabase();
1622
+ }
1623
+
1624
+ if (!this.migrationDatabaseClient && this.migrationDatabase) {
1625
+ this.migrationDatabaseClient = this.migrationDatabase.getDataSource();
1626
+ }
1627
+
1628
+ if (!this.migrationDatabaseClient) {
1629
+ throw new Exception(
1630
+ ExceptionCode.DatabaseNotConnectedException,
1631
+ "ClickHouse migration client is not connected",
1632
+ );
1633
+ }
1634
+
1635
+ return this.migrationDatabaseClient;
1636
+ }
1637
+
1563
1638
  protected async onUpdateSuccess(
1564
1639
  onUpdate: OnUpdate<TBaseModel>,
1565
1640
  _updatedItemIds: Array<ObjectID>,
@@ -107,8 +107,12 @@ export interface TraceAnalyticsTopItem {
107
107
  export interface TraceAnalyticsTableRow {
108
108
  groupValues: Record<string, string>;
109
109
  count: number;
110
+ errorCount: number;
110
111
  avgDurationMs: number;
111
112
  p50DurationMs: number;
113
+ p90DurationMs: number;
114
+ p95DurationMs: number;
115
+ p99DurationMs: number;
112
116
  minDurationMs: number;
113
117
  maxDurationMs: number;
114
118
  }
@@ -1344,8 +1348,12 @@ export class TraceAggregationService {
1344
1348
  return {
1345
1349
  groupValues,
1346
1350
  count: Number(row["cnt"] || 0),
1351
+ errorCount: Number(row["err_cnt"] || 0),
1347
1352
  avgDurationMs: Number(row["avg_ms"] || 0),
1348
1353
  p50DurationMs: Number(row["p50_ms"] || 0),
1354
+ p90DurationMs: Number(row["p90_ms"] || 0),
1355
+ p95DurationMs: Number(row["p95_ms"] || 0),
1356
+ p99DurationMs: Number(row["p99_ms"] || 0),
1349
1357
  minDurationMs: Number(row["min_ms"] || 0),
1350
1358
  maxDurationMs: Number(row["max_ms"] || 0),
1351
1359
  };
@@ -1676,15 +1684,20 @@ export class TraceAggregationService {
1676
1684
  request.limit ?? TraceAggregationService.DEFAULT_ANALYTICS_LIMIT;
1677
1685
 
1678
1686
  /*
1679
- * The "top dimensions" table always carries the full duration stat set
1680
- * (count, avg, median, min, max) — one query answers "requests and
1681
- * median response time per tenant" without a follow-up.
1687
+ * The "top dimensions" table always carries the full stat set (count,
1688
+ * errors, avg, p50/p90/p95/p99, min, max) — one query answers "requests,
1689
+ * errors and tail latency per tenant" without a follow-up. errorCount
1690
+ * mirrors the METRIC_EXPRESSIONS convention: statusCode = 2 is an error.
1682
1691
  */
1683
1692
  const statement: Statement = new Statement();
1684
1693
  statement.append(
1685
1694
  "SELECT count() AS cnt" +
1695
+ ", countIf(statusCode = 2) AS err_cnt" +
1686
1696
  ", avg(durationUnixNano) / 1000000 AS avg_ms" +
1687
1697
  ", quantile(0.5)(durationUnixNano) / 1000000 AS p50_ms" +
1698
+ ", quantile(0.9)(durationUnixNano) / 1000000 AS p90_ms" +
1699
+ ", quantile(0.95)(durationUnixNano) / 1000000 AS p95_ms" +
1700
+ ", quantile(0.99)(durationUnixNano) / 1000000 AS p99_ms" +
1688
1701
  ", min(durationUnixNano) / 1000000 AS min_ms" +
1689
1702
  ", max(durationUnixNano) / 1000000 AS max_ms",
1690
1703
  );
@@ -328,10 +328,20 @@ describe("TraceAggregationService", () => {
328
328
 
329
329
  const query: string = normalizedQuery(statement);
330
330
  expect(query).toContain("count() AS cnt");
331
+ expect(query).toContain("countIf(statusCode = 2) AS err_cnt");
331
332
  expect(query).toContain("avg(durationUnixNano) / 1000000 AS avg_ms");
332
333
  expect(query).toContain(
333
334
  "quantile(0.5)(durationUnixNano) / 1000000 AS p50_ms",
334
335
  );
336
+ expect(query).toContain(
337
+ "quantile(0.9)(durationUnixNano) / 1000000 AS p90_ms",
338
+ );
339
+ expect(query).toContain(
340
+ "quantile(0.95)(durationUnixNano) / 1000000 AS p95_ms",
341
+ );
342
+ expect(query).toContain(
343
+ "quantile(0.99)(durationUnixNano) / 1000000 AS p99_ms",
344
+ );
335
345
  expect(query).toContain("min(durationUnixNano) / 1000000 AS min_ms");
336
346
  expect(query).toContain("max(durationUnixNano) / 1000000 AS max_ms");
337
347
  expect(query).toContain(" AND name IN (");
@@ -52,6 +52,8 @@ describe("<Pill />", () => {
52
52
  const { container } = render(
53
53
  <Pill text="Love" color={color} icon={IconProp.Label} />,
54
54
  );
55
- expect(container.querySelector('[role="icon"]')).not.toBeNull();
55
+ // The icon renders an inline <svg>. (It no longer uses the invalid
56
+ // role="icon" ARIA role, which was removed for WCAG 4.1.2 compliance.)
57
+ expect(container.querySelector("svg")).not.toBeNull();
56
58
  });
57
59
  });
@@ -7,6 +7,7 @@ enum DashboardComponentType {
7
7
  LogStream = `LogStream`,
8
8
  TraceList = `TraceList`,
9
9
  TraceChart = `TraceChart`,
10
+ TraceTable = `TraceTable`,
10
11
  IncidentList = `IncidentList`,
11
12
  AlertList = `AlertList`,
12
13
  MonitorList = `MonitorList`,
@@ -0,0 +1,39 @@
1
+ import ObjectID from "../../ObjectID";
2
+ import DashboardComponentType from "../DashboardComponentType";
3
+ import BaseComponent from "./DashboardBaseComponent";
4
+
5
+ export default interface DashboardTraceTableComponent extends BaseComponent {
6
+ componentType: DashboardComponentType.TraceTable;
7
+ componentId: ObjectID;
8
+ arguments: {
9
+ title?: string | undefined;
10
+ // Substring filter on span name (e.g. "/Shipment/ShipShipment").
11
+ spanNameContains?: string | undefined;
12
+ /*
13
+ * Attribute equality filters, ANDed. The structured editor stores a
14
+ * key/value record (e.g. { "url.host": "torginol.starship.online" }).
15
+ * A legacy "key=value; key2=value2" string is still read for widgets
16
+ * saved before the structured editor existed.
17
+ */
18
+ attributeFilters?:
19
+ | string
20
+ | Record<string, string | number | boolean>
21
+ | undefined;
22
+ /*
23
+ * The dimension to break the table into rows by: a span attribute key
24
+ * (e.g. url.host, resource.service.instance.id) or a top-level column
25
+ * (name, primaryEntityId, statusCode, kind). One row per value. Unlike
26
+ * the trace chart's optional split, this is REQUIRED — the table is a
27
+ * "top dimensions" breakdown, so an unset group-by renders an empty
28
+ * prompt instead of a query.
29
+ */
30
+ groupByAttribute?: string | undefined;
31
+ // Cap on the number of rows (default 10).
32
+ topLimit?: number | undefined;
33
+ /*
34
+ * Include non-root spans. Off by default so "Requests" matches the
35
+ * traces explorer, which is root-spans-only.
36
+ */
37
+ includeChildSpans?: boolean | undefined;
38
+ };
39
+ }
@@ -1,64 +1,248 @@
1
1
  import React, { FunctionComponent, ReactElement } from "react";
2
- import RangeStartAndEndDateTime from "../../../Types/Time/RangeStartAndEndDateTime";
2
+ import RangeStartAndEndDateTime, {
3
+ RangeStartAndEndDateTimeUtil,
4
+ } from "../../../Types/Time/RangeStartAndEndDateTime";
3
5
  import StartAndEndDate, {
4
6
  StartAndEndDateType,
5
7
  } from "../../../UI/Components/Date/StartAndEndDate";
6
8
  import InBetween from "../../../Types/BaseDatabase/InBetween";
7
9
  import TimeRange from "../../../Types/Time/TimeRange";
8
- import Dropdown, {
9
- DropdownOption,
10
- DropdownValue,
11
- } from "../../../UI/Components/Dropdown/Dropdown";
12
- import DropdownUtil from "../../../UI/Utils/Dropdown";
10
+ import OneUptimeDate from "../../../Types/Date";
11
+ import Icon from "../Icon/Icon";
12
+ import IconProp from "../../../Types/Icon/IconProp";
13
13
 
14
14
  export interface ComponentProps {
15
15
  value?: RangeStartAndEndDateTime | undefined;
16
16
  onChange: (startAndEndDate: RangeStartAndEndDateTime) => void;
17
+ /*
18
+ * When provided, clicking a quick range immediately commits the selection
19
+ * (and the parent is expected to close the picker). Custom ranges always
20
+ * go through onChange so the user can fine-tune before applying.
21
+ */
22
+ onApply?: ((startAndEndDate: RangeStartAndEndDateTime) => void) | undefined;
17
23
  }
18
24
 
19
- const DashboardStartAndEndDateEditElement: FunctionComponent<ComponentProps> = (
25
+ /*
26
+ * Quick ranges shown in the left rail, in display order. Custom is handled
27
+ * separately below the list.
28
+ */
29
+ const QUICK_RANGES: Array<TimeRange> = [
30
+ TimeRange.PAST_FIVE_MINS,
31
+ TimeRange.PAST_FIFTEEN_MINS,
32
+ TimeRange.PAST_THIRTY_MINS,
33
+ TimeRange.PAST_ONE_HOUR,
34
+ TimeRange.PAST_TWO_HOURS,
35
+ TimeRange.PAST_THREE_HOURS,
36
+ TimeRange.PAST_ONE_DAY,
37
+ TimeRange.PAST_TWO_DAYS,
38
+ TimeRange.PAST_ONE_WEEK,
39
+ TimeRange.PAST_TWO_WEEKS,
40
+ TimeRange.PAST_ONE_MONTH,
41
+ TimeRange.PAST_THREE_MONTHS,
42
+ ];
43
+
44
+ const RangeStartAndEndDateEdit: FunctionComponent<ComponentProps> = (
20
45
  props: ComponentProps,
21
46
  ): ReactElement => {
22
- const dropdownOptions: DropdownOption[] =
23
- DropdownUtil.getDropdownOptionsFromEnum(TimeRange);
24
- const defaultDropdownOption: DropdownOption =
25
- dropdownOptions.find((option: DropdownOption) => {
26
- return option.value === TimeRange.PAST_ONE_HOUR;
27
- }) || dropdownOptions[0]!;
28
- const selectedDropdownnOption: DropdownOption =
29
- dropdownOptions.find((option: DropdownOption) => {
30
- return option.value === props.value?.range;
31
- }) || defaultDropdownOption;
47
+ const selectedRange: TimeRange =
48
+ props.value?.range || TimeRange.PAST_ONE_HOUR;
49
+ const isCustom: boolean = selectedRange === TimeRange.CUSTOM;
50
+
51
+ /*
52
+ * Absolute window for the current selection. Used to preview a quick range
53
+ * and to seed the custom editor when the user switches to it.
54
+ */
55
+ const resolvedRange: InBetween<Date> =
56
+ RangeStartAndEndDateTimeUtil.getStartAndEndDate(
57
+ props.value || { range: TimeRange.PAST_ONE_HOUR },
58
+ );
59
+
60
+ const formatDateTime: (date: Date) => string = (date: Date): string => {
61
+ return OneUptimeDate.getDateAsUserFriendlyLocalFormattedString(
62
+ date,
63
+ false,
64
+ true,
65
+ );
66
+ };
67
+
68
+ const getDurationText: (start: Date, end: Date) => string = (
69
+ start: Date,
70
+ end: Date,
71
+ ): string => {
72
+ const seconds: number = OneUptimeDate.getDifferenceInSeconds(end, start);
73
+
74
+ if (seconds <= 0) {
75
+ return "";
76
+ }
77
+
78
+ return OneUptimeDate.secondsToFormattedFriendlyTimeString(seconds).trim();
79
+ };
80
+
81
+ const onSelectQuickRange: (range: TimeRange) => void = (
82
+ range: TimeRange,
83
+ ): void => {
84
+ const next: RangeStartAndEndDateTime = { range };
85
+
86
+ if (props.onApply) {
87
+ props.onApply(next);
88
+ } else {
89
+ props.onChange(next);
90
+ }
91
+ };
92
+
93
+ const onSelectCustom: () => void = (): void => {
94
+ props.onChange({
95
+ range: TimeRange.CUSTOM,
96
+ startAndEndDate: props.value?.startAndEndDate || resolvedRange,
97
+ });
98
+ };
99
+
100
+ const customStart: Date | undefined =
101
+ props.value?.startAndEndDate?.startValue;
102
+ const customEnd: Date | undefined = props.value?.startAndEndDate?.endValue;
103
+ /*
104
+ * Only show the duration hint for a valid range. getDifferenceInSeconds is
105
+ * absolute, so without this guard an inverted (end-before-start) range would
106
+ * read as a positive "Showing N of data" while Apply is disabled.
107
+ */
108
+ const customDuration: string =
109
+ customStart && customEnd && OneUptimeDate.isAfter(customEnd, customStart)
110
+ ? getDurationText(customStart, customEnd)
111
+ : "";
112
+
113
+ const previewDuration: string = getDurationText(
114
+ resolvedRange.startValue,
115
+ resolvedRange.endValue,
116
+ );
32
117
 
33
118
  return (
34
- <div>
35
- <Dropdown
36
- value={selectedDropdownnOption}
37
- onChange={(range: DropdownValue | Array<DropdownValue> | null) => {
38
- props.onChange({
39
- range: range as TimeRange,
40
- });
41
- }}
42
- options={dropdownOptions}
43
- />
44
- {/* Start and End Date */}
45
- {props.value?.range === TimeRange.CUSTOM && (
46
- <StartAndEndDate
47
- type={StartAndEndDateType.DateTime}
48
- value={props.value?.startAndEndDate || undefined}
49
- hideTimeButtons={true}
50
- onValueChanged={(startAndEndDate: InBetween<Date> | null) => {
51
- if (startAndEndDate) {
52
- props.onChange({
53
- range: TimeRange.CUSTOM,
54
- startAndEndDate,
55
- });
56
- }
57
- }}
58
- />
59
- )}
119
+ <div className="flex flex-col md:flex-row md:divide-x md:divide-gray-200">
120
+ {/* Left rail: quick ranges */}
121
+ <div
122
+ className="md:w-52 md:shrink-0 md:pr-4 md:max-h-96 md:overflow-y-auto"
123
+ role="radiogroup"
124
+ aria-label="Time range"
125
+ >
126
+ <div className="px-1 pb-2 text-xs font-semibold uppercase tracking-wide text-gray-400">
127
+ Quick ranges
128
+ </div>
129
+ <div className="grid grid-cols-2 gap-1 md:grid-cols-1">
130
+ {QUICK_RANGES.map((range: TimeRange) => {
131
+ const isActive: boolean = !isCustom && selectedRange === range;
132
+
133
+ return (
134
+ <button
135
+ key={range}
136
+ type="button"
137
+ role="radio"
138
+ aria-checked={isActive}
139
+ onClick={() => {
140
+ return onSelectQuickRange(range);
141
+ }}
142
+ className={`w-full rounded-md px-3 py-1.5 text-left text-sm transition-colors ${
143
+ isActive
144
+ ? "bg-indigo-50 font-medium text-indigo-700"
145
+ : "text-gray-700 hover:bg-gray-100"
146
+ }`}
147
+ >
148
+ {range}
149
+ </button>
150
+ );
151
+ })}
152
+ </div>
153
+ <div className="mt-2 border-t border-gray-100 pt-2">
154
+ <button
155
+ type="button"
156
+ role="radio"
157
+ aria-checked={isCustom}
158
+ onClick={onSelectCustom}
159
+ className={`flex w-full items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors ${
160
+ isCustom
161
+ ? "bg-indigo-50 font-medium text-indigo-700"
162
+ : "text-gray-700 hover:bg-gray-100"
163
+ }`}
164
+ >
165
+ <Icon icon={IconProp.Calendar} className="h-4 w-4" />
166
+ Custom range
167
+ </button>
168
+ </div>
169
+ </div>
170
+
171
+ {/* Right pane: custom editor or quick-range preview */}
172
+ <div className="mt-4 md:mt-0 md:flex-1 md:pl-4">
173
+ {isCustom ? (
174
+ <div>
175
+ <div className="text-sm font-medium text-gray-900">
176
+ Custom range
177
+ </div>
178
+ <div className="mt-0.5 text-xs text-gray-500">
179
+ Pick the exact start and end date &amp; time.
180
+ </div>
181
+ <StartAndEndDate
182
+ type={StartAndEndDateType.DateTime}
183
+ value={props.value?.startAndEndDate || resolvedRange}
184
+ hideTimeButtons={true}
185
+ onValueChanged={(startAndEndDate: InBetween<Date> | null) => {
186
+ if (startAndEndDate) {
187
+ props.onChange({
188
+ range: TimeRange.CUSTOM,
189
+ startAndEndDate,
190
+ });
191
+ }
192
+ }}
193
+ />
194
+ {customDuration ? (
195
+ <div className="mt-2 flex items-center gap-1.5 text-xs text-gray-500">
196
+ <Icon icon={IconProp.Clock} className="h-3.5 w-3.5" />
197
+ <span>
198
+ Showing{" "}
199
+ <span className="font-medium text-gray-700">
200
+ {customDuration}
201
+ </span>{" "}
202
+ of data.
203
+ </span>
204
+ </div>
205
+ ) : (
206
+ <></>
207
+ )}
208
+ </div>
209
+ ) : (
210
+ <div>
211
+ <div className="text-sm font-medium text-gray-900">
212
+ {selectedRange}
213
+ </div>
214
+ <div className="mt-0.5 text-xs text-gray-500">
215
+ Relative to now &mdash; updates every time the dashboard loads.
216
+ </div>
217
+ <div className="mt-3 space-y-2 rounded-md border border-gray-200 bg-gray-50 p-3">
218
+ <div className="flex items-center justify-between text-sm">
219
+ <span className="text-gray-500">From</span>
220
+ <span className="font-medium text-gray-900">
221
+ {formatDateTime(resolvedRange.startValue)}
222
+ </span>
223
+ </div>
224
+ <div className="flex items-center justify-between text-sm">
225
+ <span className="text-gray-500">To</span>
226
+ <span className="font-medium text-gray-900">
227
+ {formatDateTime(resolvedRange.endValue)}
228
+ </span>
229
+ </div>
230
+ {previewDuration ? (
231
+ <div className="flex items-center justify-between border-t border-gray-200 pt-2 text-sm">
232
+ <span className="text-gray-500">Duration</span>
233
+ <span className="font-medium text-gray-900">
234
+ {previewDuration}
235
+ </span>
236
+ </div>
237
+ ) : (
238
+ <></>
239
+ )}
240
+ </div>
241
+ </div>
242
+ )}
243
+ </div>
60
244
  </div>
61
245
  );
62
246
  };
63
247
 
64
- export default DashboardStartAndEndDateEditElement;
248
+ export default RangeStartAndEndDateEdit;