@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.
- package/Server/Infrastructure/ClickhouseConfig.ts +21 -0
- package/Server/Infrastructure/ClickhouseDatabase.ts +12 -0
- package/Server/Services/AnalyticsDatabaseService.ts +79 -4
- package/Server/Services/TraceAggregationService.ts +16 -3
- package/Tests/Server/Services/TraceAggregationService.test.ts +10 -0
- package/Tests/UI/Components/Pill.test.tsx +3 -1
- package/Types/Dashboard/DashboardComponentType.ts +1 -0
- package/Types/Dashboard/DashboardComponents/DashboardTraceTableComponent.ts +39 -0
- package/UI/Components/Date/RangeStartAndEndDateEdit.tsx +228 -44
- package/UI/Components/Date/RangeStartAndEndDateView.tsx +73 -21
- package/UI/Components/EditionLabel/EditionLabel.tsx +6 -1
- package/UI/Components/Footer/Footer.tsx +1 -1
- package/UI/Components/GanttChart/ChartContainer.tsx +8 -1
- package/UI/Components/Icon/Icon.tsx +13 -1
- package/UI/Components/Image/Image.tsx +6 -1
- package/UI/Components/Link/Link.tsx +22 -0
- package/UI/Components/MasterPage/MasterPage.tsx +30 -1
- package/UI/Components/Page/Page.tsx +29 -2
- package/UI/Components/Pagination/Pagination.tsx +110 -180
- package/Utils/Dashboard/Components/DashboardTraceTableComponent.ts +65 -0
- package/Utils/Dashboard/Components/Index.ts +7 -0
- package/build/dist/Server/Infrastructure/ClickhouseConfig.js +17 -0
- package/build/dist/Server/Infrastructure/ClickhouseConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/ClickhouseDatabase.js +10 -1
- package/build/dist/Server/Infrastructure/ClickhouseDatabase.js.map +1 -1
- package/build/dist/Server/Services/AnalyticsDatabaseService.js +59 -8
- package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
- package/build/dist/Server/Services/TraceAggregationService.js +12 -3
- package/build/dist/Server/Services/TraceAggregationService.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponentType.js +1 -0
- package/build/dist/Types/Dashboard/DashboardComponentType.js.map +1 -1
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardTraceTableComponent.js +2 -0
- package/build/dist/Types/Dashboard/DashboardComponents/DashboardTraceTableComponent.js.map +1 -0
- package/build/dist/UI/Components/Date/RangeStartAndEndDateEdit.js +116 -26
- package/build/dist/UI/Components/Date/RangeStartAndEndDateEdit.js.map +1 -1
- package/build/dist/UI/Components/Date/RangeStartAndEndDateView.js +42 -16
- package/build/dist/UI/Components/Date/RangeStartAndEndDateView.js.map +1 -1
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js +1 -1
- package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
- package/build/dist/UI/Components/Footer/Footer.js +1 -1
- package/build/dist/UI/Components/GanttChart/ChartContainer.js +9 -1
- package/build/dist/UI/Components/GanttChart/ChartContainer.js.map +1 -1
- package/build/dist/UI/Components/Icon/Icon.js +2 -2
- package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
- package/build/dist/UI/Components/Image/Image.js +8 -1
- package/build/dist/UI/Components/Image/Image.js.map +1 -1
- package/build/dist/UI/Components/Link/Link.js +9 -0
- package/build/dist/UI/Components/Link/Link.js.map +1 -1
- package/build/dist/UI/Components/MasterPage/MasterPage.js +2 -1
- package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
- package/build/dist/UI/Components/Page/Page.js +24 -1
- package/build/dist/UI/Components/Page/Page.js.map +1 -1
- package/build/dist/UI/Components/Pagination/Pagination.js +44 -94
- package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
- package/build/dist/Utils/Dashboard/Components/DashboardTraceTableComponent.js +51 -0
- package/build/dist/Utils/Dashboard/Components/DashboardTraceTableComponent.js.map +1 -0
- package/build/dist/Utils/Dashboard/Components/Index.js +4 -0
- package/build/dist/Utils/Dashboard/Components/Index.js.map +1 -1
- 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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
1680
|
-
*
|
|
1681
|
-
*
|
|
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
|
-
|
|
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
|
});
|
|
@@ -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
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 & 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 — 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
|
|
248
|
+
export default RangeStartAndEndDateEdit;
|