@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.
- package/Server/Infrastructure/ClickhouseConfig.ts +21 -0
- package/Server/Infrastructure/ClickhouseDatabase.ts +12 -0
- package/Server/Services/AnalyticsDatabaseService.ts +79 -4
- package/Tests/UI/Components/Pill.test.tsx +3 -1
- package/UI/Components/EditionLabel/EditionLabel.tsx +6 -1
- package/UI/Components/Footer/Footer.tsx +1 -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/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/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/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/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>,
|
|
@@ -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
|
});
|
|
@@ -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
|
-
|
|
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-
|
|
69
|
+
<p className="text-center text-sm text-gray-500">
|
|
70
70
|
© {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
|
|
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
|
-
|
|
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.
|
|
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
|
-
<
|
|
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
|
-
</
|
|
147
|
+
</div>
|
|
121
148
|
)}
|
|
122
149
|
|
|
123
150
|
{!props.sideMenu && !props.isLoading && props.children}
|