@oneuptime/common 11.3.2 → 11.3.3

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 (34) 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/Tests/UI/Components/Pill.test.tsx +3 -1
  5. package/UI/Components/EditionLabel/EditionLabel.tsx +6 -1
  6. package/UI/Components/Footer/Footer.tsx +1 -1
  7. package/UI/Components/Icon/Icon.tsx +13 -1
  8. package/UI/Components/Image/Image.tsx +6 -1
  9. package/UI/Components/Link/Link.tsx +22 -0
  10. package/UI/Components/MasterPage/MasterPage.tsx +30 -1
  11. package/UI/Components/Page/Page.tsx +29 -2
  12. package/UI/Components/Pagination/Pagination.tsx +110 -180
  13. package/build/dist/Server/Infrastructure/ClickhouseConfig.js +17 -0
  14. package/build/dist/Server/Infrastructure/ClickhouseConfig.js.map +1 -1
  15. package/build/dist/Server/Infrastructure/ClickhouseDatabase.js +10 -1
  16. package/build/dist/Server/Infrastructure/ClickhouseDatabase.js.map +1 -1
  17. package/build/dist/Server/Services/AnalyticsDatabaseService.js +59 -8
  18. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  19. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +1 -1
  20. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  21. package/build/dist/UI/Components/Footer/Footer.js +1 -1
  22. package/build/dist/UI/Components/Icon/Icon.js +2 -2
  23. package/build/dist/UI/Components/Icon/Icon.js.map +1 -1
  24. package/build/dist/UI/Components/Image/Image.js +8 -1
  25. package/build/dist/UI/Components/Image/Image.js.map +1 -1
  26. package/build/dist/UI/Components/Link/Link.js +9 -0
  27. package/build/dist/UI/Components/Link/Link.js.map +1 -1
  28. package/build/dist/UI/Components/MasterPage/MasterPage.js +2 -1
  29. package/build/dist/UI/Components/MasterPage/MasterPage.js.map +1 -1
  30. package/build/dist/UI/Components/Page/Page.js +24 -1
  31. package/build/dist/UI/Components/Page/Page.js.map +1 -1
  32. package/build/dist/UI/Components/Pagination/Pagination.js +44 -94
  33. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  34. 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>,
@@ -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
  });
@@ -462,7 +462,12 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
462
462
  type="button"
463
463
  onClick={openDialog}
464
464
  className={`${pillClassName} ${props.className ? props.className : ""}`}
465
- aria-label={`${editionName} details`}
465
+ /*
466
+ * Accessible name must include the visible text (WCAG 2.5.3 Label in
467
+ * Name) so voice-control users can activate it by the words they see
468
+ * ("{editionName}" and "{ctaLabel}", e.g. "Learn more").
469
+ */
470
+ aria-label={`${editionName}, ${ctaLabel}`}
466
471
  >
467
472
  {showAlertedPill && (
468
473
  <Icon
@@ -66,7 +66,7 @@ const Footer: FunctionComponent<ComponentProps> = (
66
66
  {/* Copyright: Show on mobile, hide on larger screens unless specified */}
67
67
  <div className="mt-5 md:order-1 md:mt-0 block md:hidden lg:block">
68
68
  {props.copyright && (
69
- <p className="text-center text-sm text-gray-400">
69
+ <p className="text-center text-sm text-gray-500">
70
70
  &copy; {props.copyright}
71
71
  </p>
72
72
  )}
@@ -35,6 +35,14 @@ export interface ComponentProps {
35
35
  onClick?: (() => void) | undefined;
36
36
  type?: IconType | undefined;
37
37
  style?: React.CSSProperties | undefined;
38
+ /*
39
+ * When the icon conveys meaning on its own (e.g. a standalone status or
40
+ * severity indicator that is not described by adjacent text), pass an
41
+ * ariaLabel so the icon is exposed to assistive technology as an image
42
+ * with an accessible name (WCAG 1.1.1). When omitted, the icon is treated
43
+ * as decorative and hidden from assistive technology.
44
+ */
45
+ ariaLabel?: string | undefined;
38
46
  "data-testid"?: string;
39
47
  }
40
48
  const Icon: FunctionComponent<ComponentProps> = ({
@@ -46,6 +54,7 @@ const Icon: FunctionComponent<ComponentProps> = ({
46
54
  onClick,
47
55
  type,
48
56
  style,
57
+ ariaLabel,
49
58
  "data-testid": dataTestId,
50
59
  }: ComponentProps): ReactElement => {
51
60
  let sizeClassName: string = "";
@@ -113,7 +122,10 @@ const Icon: FunctionComponent<ComponentProps> = ({
113
122
  | undefined,
114
123
  ): ReactElement => {
115
124
  return (
116
- <div role="icon">
125
+ <div
126
+ role={ariaLabel ? "img" : undefined}
127
+ aria-label={ariaLabel || undefined}
128
+ >
117
129
  <svg
118
130
  onClick={() => {
119
131
  if (onClick) {
@@ -41,7 +41,12 @@ const Image: FunctionComponent<ComponentProps> = (
41
41
  props.onClick?.();
42
42
  }}
43
43
  data-testid={props["data-testid"]}
44
- alt={props.alt}
44
+ /*
45
+ * Always render an alt attribute so the <img> is never missing it
46
+ * (WCAG 1.1.1). Callers should pass a descriptive alt for meaningful
47
+ * images (e.g. avatars); an empty string marks the image decorative.
48
+ */
49
+ alt={props.alt ?? ""}
45
50
  src={url}
46
51
  height={props.height}
47
52
  className={props.className}
@@ -33,6 +33,28 @@ const Link: FunctionComponent<ComponentProps> = (
33
33
  children = props.children;
34
34
  }
35
35
 
36
+ /*
37
+ * Neither a destination nor an action: this is static text, not a link or a
38
+ * button. Render a <span> rather than an <a> with no href, which is
39
+ * ambiguous to assistive technology (is it a link, a button, or just text?
40
+ * WCAG 4.1.2 Name, Role, Value).
41
+ */
42
+ if (!props.to && !props.onClick) {
43
+ return (
44
+ <span
45
+ id={props.id}
46
+ className={`cursor-default ${props.className || ""}`}
47
+ onMouseOver={props.onMouseOver}
48
+ onMouseOut={props.onMouseOut}
49
+ onMouseLeave={props.onMouseLeave}
50
+ style={props.style}
51
+ title={props.title}
52
+ >
53
+ {children}
54
+ </span>
55
+ );
56
+ }
57
+
36
58
  const linkProps: JSONObject = {};
37
59
 
38
60
  /*
@@ -15,6 +15,12 @@ export interface ComponentProps {
15
15
  className?: string | undefined;
16
16
  hideHeader?: boolean | undefined;
17
17
  makeTopSectionUnstick?: boolean | undefined;
18
+ /*
19
+ * Set when the consumer renders its own <main id="main-content"> landmark
20
+ * and skip link inside children (e.g. the status page), so this component
21
+ * does not add a second, nested main landmark or a duplicate skip link.
22
+ */
23
+ disableMainContentWrapper?: boolean | undefined;
18
24
  }
19
25
 
20
26
  const MasterPage: FunctionComponent<ComponentProps> = (
@@ -42,6 +48,19 @@ const MasterPage: FunctionComponent<ComponentProps> = (
42
48
  <React.Fragment>
43
49
  {isOnline && (
44
50
  <div className={props.className}>
51
+ {/*
52
+ * Skip link so keyboard and screen-reader users can bypass the
53
+ * repeated header/navigation and jump straight to the page content
54
+ * (WCAG 2.4.1 Bypass Blocks). Visually hidden until focused.
55
+ */}
56
+ {!props.disableMainContentWrapper && (
57
+ <a
58
+ href="#main-content"
59
+ className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-50 focus:rounded-md focus:bg-indigo-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white focus:shadow-lg"
60
+ >
61
+ Skip to main content
62
+ </a>
63
+ )}
45
64
  <div
46
65
  className={props.makeTopSectionUnstick ? "" : "sticky top-0 z-10"}
47
66
  >
@@ -53,7 +72,17 @@ const MasterPage: FunctionComponent<ComponentProps> = (
53
72
  />
54
73
  </div>
55
74
 
56
- {props.children}
75
+ {props.disableMainContentWrapper ? (
76
+ props.children
77
+ ) : (
78
+ <main
79
+ id="main-content"
80
+ tabIndex={-1}
81
+ className="focus:outline-none"
82
+ >
83
+ {props.children}
84
+ </main>
85
+ )}
57
86
 
58
87
  {props.footer && props.footer}
59
88
  </div>
@@ -40,6 +40,33 @@ const Page: FunctionComponent<ComponentProps> = (
40
40
  }
41
41
  }, [props.breadcrumbLinks]);
42
42
 
43
+ /*
44
+ * Give each page a unique, descriptive document title (WCAG 2.4.2 Page
45
+ * Titled). Without this, every page keeps the generic title set once at app
46
+ * startup (e.g. "OneUptime | Dashboard"), which does not describe the page.
47
+ * Prefer the breadcrumb trail (most specific page last) and fall back to the
48
+ * page title.
49
+ */
50
+ useEffect(() => {
51
+ const breadcrumbTitle: string | undefined =
52
+ props.breadcrumbLinks && props.breadcrumbLinks.length > 0
53
+ ? props.breadcrumbLinks
54
+ .map((link: Link) => {
55
+ return translateString(link.title);
56
+ })
57
+ .filter((value: string | undefined): value is string => {
58
+ return Boolean(value);
59
+ })
60
+ .join(" - ")
61
+ : undefined;
62
+
63
+ const pageTitle: string | undefined = breadcrumbTitle || translatedTitle;
64
+
65
+ if (pageTitle) {
66
+ document.title = `OneUptime | ${pageTitle}`;
67
+ }
68
+ }, [translatedTitle, props.breadcrumbLinks]);
69
+
43
70
  if (props.error) {
44
71
  return <ErrorMessage message={props.error} />;
45
72
  }
@@ -104,7 +131,7 @@ const Page: FunctionComponent<ComponentProps> = (
104
131
  )}
105
132
 
106
133
  {props.sideMenu && (
107
- <main className="mx-auto max-w-full pb-10">
134
+ <div className="mx-auto max-w-full pb-10">
108
135
  <div className="flex flex-col md:flex-row md:gap-4 lg:gap-5">
109
136
  {props.sideMenu}
110
137
 
@@ -117,7 +144,7 @@ const Page: FunctionComponent<ComponentProps> = (
117
144
  </div>
118
145
  )}
119
146
  </div>
120
- </main>
147
+ </div>
121
148
  )}
122
149
 
123
150
  {!props.sideMenu && !props.isLoading && props.children}