@oneuptime/common 12.0.0 → 12.0.1
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/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.ts +12 -2
- package/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.ts +12 -2
- package/Server/Services/LlmProviderService.ts +69 -9
- package/Server/Services/RunnerService.ts +1 -7
- package/Server/Utils/Monitor/Criteria/CompareCriteria.ts +12 -0
- package/Tests/App/Dashboard/RunnerInstallInstructions.test.tsx +119 -0
- package/Tests/App/Dashboard/RunnerStatus.test.tsx +274 -0
- package/Tests/Server/Services/LlmProviderUsableByProject.test.ts +293 -0
- package/Tests/Server/Utils/AI/ToolArgsExtractors.test.ts +141 -0
- package/Tests/Server/Utils/AI/Toolbox/WidgetBuilder.test.ts +205 -0
- package/Tests/Server/Utils/Monitor/Criteria/CompareCriteria.test.ts +897 -0
- package/Tests/Types/Runner/RunnerLiveStatus.test.ts +320 -0
- package/Tests/UI/Components/AiInvestigationSettingsCard.test.tsx +285 -0
- package/Tests/UI/Components/Detail/EntityFields.test.tsx +166 -0
- package/Types/Runbook/RunbookStep.ts +15 -0
- package/Types/Runner/RunnerLiveStatus.ts +114 -0
- package/UI/Components/Detail/Detail.tsx +155 -0
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js +16 -4
- package/build/dist/Models/DatabaseModels/OnCallDutyPolicyScheduleLayer.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js +11 -1
- package/build/dist/Server/Infrastructure/Postgres/LocalMigrationGenerationDataSource.js.map +1 -1
- package/build/dist/Server/Services/LlmProviderService.js +61 -8
- package/build/dist/Server/Services/LlmProviderService.js.map +1 -1
- package/build/dist/Server/Services/RunnerService.js +1 -6
- package/build/dist/Server/Services/RunnerService.js.map +1 -1
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js +11 -0
- package/build/dist/Server/Utils/Monitor/Criteria/CompareCriteria.js.map +1 -1
- package/build/dist/Types/Runbook/RunbookStep.js.map +1 -1
- package/build/dist/Types/Runner/RunnerLiveStatus.js +69 -0
- package/build/dist/Types/Runner/RunnerLiveStatus.js.map +1 -0
- package/build/dist/UI/Components/Detail/Detail.js +97 -0
- package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
- package/package.json +1 -1
|
@@ -571,10 +571,19 @@ export default class OnCallDutyPolicyScheduleLayer extends BaseModel {
|
|
|
571
571
|
Permission.EditOnCallDutyPolicyScheduleLayer,
|
|
572
572
|
],
|
|
573
573
|
})
|
|
574
|
+
/*
|
|
575
|
+
* The default is `.toJSON()` rather than the Recurring instance itself. Both
|
|
576
|
+
* serialize to the identical JSON, so the DDL is unchanged — but TypeORM
|
|
577
|
+
* compares a jsonb default by deep-comparing the entity default against the
|
|
578
|
+
* value parsed back out of the database, and that comparison rejects any pair
|
|
579
|
+
* whose constructors differ (OrmUtils.compare2Objects). An instance never
|
|
580
|
+
* equals the plain object Postgres hands back, so every generated migration
|
|
581
|
+
* re-emitted this SET DEFAULT as phantom drift. A plain object compares equal.
|
|
582
|
+
*/
|
|
574
583
|
@Column({
|
|
575
584
|
nullable: false,
|
|
576
585
|
type: ColumnType.JSON,
|
|
577
|
-
default: Recurring.getDefault(),
|
|
586
|
+
default: Recurring.getDefault().toJSON(),
|
|
578
587
|
transformer: Recurring.getDatabaseTransformer(),
|
|
579
588
|
})
|
|
580
589
|
public rotation?: Recurring = undefined;
|
|
@@ -654,10 +663,11 @@ export default class OnCallDutyPolicyScheduleLayer extends BaseModel {
|
|
|
654
663
|
Permission.EditOnCallDutyPolicyScheduleLayer,
|
|
655
664
|
],
|
|
656
665
|
})
|
|
666
|
+
// Plain object, not the instance — see the note on `rotation` above.
|
|
657
667
|
@Column({
|
|
658
668
|
nullable: false,
|
|
659
669
|
type: ColumnType.JSON,
|
|
660
|
-
default: RestrictionTimes.getDefault(),
|
|
670
|
+
default: RestrictionTimes.getDefault().toJSON(),
|
|
661
671
|
transformer: RestrictionTimes.getDatabaseTransformer(),
|
|
662
672
|
})
|
|
663
673
|
public restrictionTimes?: RestrictionTimes = undefined;
|
|
@@ -1,10 +1,20 @@
|
|
|
1
1
|
import dataSourceOptions from "./DataSourceOptions";
|
|
2
2
|
import { DataSource } from "typeorm";
|
|
3
3
|
|
|
4
|
+
/*
|
|
5
|
+
* The app reaches Postgres over the compose network as `postgres:5432`, but the
|
|
6
|
+
* migration CLI runs on the host, where the same server is published on
|
|
7
|
+
* localhost:5400 — hence the defaults below.
|
|
8
|
+
*
|
|
9
|
+
* DATABASE_MIGRATIONS_HOST / DATABASE_MIGRATIONS_PORT (already in
|
|
10
|
+
* config.example.env, already honoured by migration-run.sh) override them, so
|
|
11
|
+
* the schema-drift job can point this at a CI service container on a different
|
|
12
|
+
* port without editing the file.
|
|
13
|
+
*/
|
|
4
14
|
const dataSourceOptionToMigrate: any = {
|
|
5
15
|
...dataSourceOptions,
|
|
6
|
-
host: "localhost",
|
|
7
|
-
port: 5400,
|
|
16
|
+
host: process.env["DATABASE_MIGRATIONS_HOST"] || "localhost",
|
|
17
|
+
port: parseInt(process.env["DATABASE_MIGRATIONS_PORT"] || "5400", 10),
|
|
8
18
|
};
|
|
9
19
|
|
|
10
20
|
const PostgresDataSource: DataSource = new DataSource(
|
|
@@ -265,6 +265,68 @@ export class Service extends DatabaseService<Model> {
|
|
|
265
265
|
});
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
+
/*
|
|
269
|
+
* THE rule for "may this project use this provider?": a global provider is
|
|
270
|
+
* shared with everyone, and a project-owned provider belongs only to its
|
|
271
|
+
* own project. Every caller that honours a caller-supplied llmProviderId
|
|
272
|
+
* routes through this one predicate so the checks cannot drift apart —
|
|
273
|
+
* a second, subtly different copy of this comparison is how one entry point
|
|
274
|
+
* ends up accepting a provider another one rejects.
|
|
275
|
+
*/
|
|
276
|
+
private isProviderUsableBy(provider: Model, projectId: ObjectID): boolean {
|
|
277
|
+
if (provider.isGlobalLlm === true) {
|
|
278
|
+
return true;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return Boolean(
|
|
282
|
+
provider.projectId &&
|
|
283
|
+
provider.projectId.toString() === projectId.toString(),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/*
|
|
288
|
+
* Can this project run against this provider id? Answers the question
|
|
289
|
+
* without loading secrets — callers that only need a yes/no (the runbook AI
|
|
290
|
+
* step validating a pinned provider before it runs) must not pull an apiKey
|
|
291
|
+
* into memory to get it. A provider that does not exist, or belongs to
|
|
292
|
+
* another project, is not usable.
|
|
293
|
+
*/
|
|
294
|
+
@CaptureSpan()
|
|
295
|
+
public async isProviderUsableByProject(data: {
|
|
296
|
+
projectId: ObjectID;
|
|
297
|
+
llmProviderId: ObjectID;
|
|
298
|
+
}): Promise<boolean> {
|
|
299
|
+
/*
|
|
300
|
+
* _id is a uuid column: querying it with a non-uuid string is a Postgres
|
|
301
|
+
* cast error, not an empty result. A caller-supplied id reaches us
|
|
302
|
+
* straight from an unvalidated JSON config, so shape-check before the
|
|
303
|
+
* query and report "not usable" rather than throwing a driver error.
|
|
304
|
+
*/
|
|
305
|
+
if (!ObjectID.isValidUUID(data.llmProviderId.toString())) {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const provider: Model | null = await this.findOneBy({
|
|
310
|
+
query: {
|
|
311
|
+
_id: data.llmProviderId.toString(),
|
|
312
|
+
},
|
|
313
|
+
select: {
|
|
314
|
+
_id: true,
|
|
315
|
+
projectId: true,
|
|
316
|
+
isGlobalLlm: true,
|
|
317
|
+
},
|
|
318
|
+
props: {
|
|
319
|
+
isRoot: true,
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
if (!provider) {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return this.isProviderUsableBy(provider, data.projectId);
|
|
328
|
+
}
|
|
329
|
+
|
|
268
330
|
/*
|
|
269
331
|
* Resolve the provider to use for a chat turn. When the user has explicitly
|
|
270
332
|
* chosen a provider (llmProviderId), use it — but only if it is actually
|
|
@@ -278,7 +340,11 @@ export class Service extends DatabaseService<Model> {
|
|
|
278
340
|
projectId: ObjectID;
|
|
279
341
|
llmProviderId?: ObjectID | undefined;
|
|
280
342
|
}): Promise<Model | null> {
|
|
281
|
-
if (
|
|
343
|
+
if (
|
|
344
|
+
data.llmProviderId &&
|
|
345
|
+
// A non-uuid id would be a Postgres cast error, not a miss. Fall back.
|
|
346
|
+
ObjectID.isValidUUID(data.llmProviderId.toString())
|
|
347
|
+
) {
|
|
282
348
|
const provider: Model | null = await this.findOneBy({
|
|
283
349
|
query: {
|
|
284
350
|
_id: data.llmProviderId.toString(),
|
|
@@ -300,14 +366,8 @@ export class Service extends DatabaseService<Model> {
|
|
|
300
366
|
},
|
|
301
367
|
});
|
|
302
368
|
|
|
303
|
-
if (provider) {
|
|
304
|
-
|
|
305
|
-
const belongsToProject: boolean =
|
|
306
|
-
provider.projectId?.toString() === data.projectId.toString();
|
|
307
|
-
|
|
308
|
-
if (isGlobal || belongsToProject) {
|
|
309
|
-
return provider;
|
|
310
|
-
}
|
|
369
|
+
if (provider && this.isProviderUsableBy(provider, data.projectId)) {
|
|
370
|
+
return provider;
|
|
311
371
|
}
|
|
312
372
|
// Fall through to default resolution when the id is invalid/inaccessible.
|
|
313
373
|
}
|
|
@@ -11,13 +11,7 @@ import OneUptimeDate from "../../Types/Date";
|
|
|
11
11
|
import { JSONObject } from "../../Types/JSON";
|
|
12
12
|
import QueryHelper from "../Types/Database/QueryHelper";
|
|
13
13
|
import CaptureSpan from "../Utils/Telemetry/CaptureSpan";
|
|
14
|
-
|
|
15
|
-
/*
|
|
16
|
-
* How recently a Runner must have heartbeated to count as online. Matches the
|
|
17
|
-
* window AIAgentService.isAgentAlive uses, and comfortably clears the Runner's
|
|
18
|
-
* 60s heartbeat interval.
|
|
19
|
-
*/
|
|
20
|
-
const RUNNER_ALIVE_WINDOW_IN_MINUTES: number = 5;
|
|
14
|
+
import { RUNNER_ALIVE_WINDOW_IN_MINUTES } from "../../Types/Runner/RunnerLiveStatus";
|
|
21
15
|
|
|
22
16
|
export class Service extends DatabaseService<Model> {
|
|
23
17
|
public constructor() {
|
|
@@ -192,6 +192,18 @@ export default class CompareCriteria {
|
|
|
192
192
|
return null;
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
/*
|
|
196
|
+
* parseInt("abc") returns NaN rather than throwing, and NaN is typeof
|
|
197
|
+
* "number" — so without this guard a non-numeric threshold would leak
|
|
198
|
+
* past callers that only check for `=== null`, leaving every numeric
|
|
199
|
+
* comparison silently false (value > NaN is always false). Treat an
|
|
200
|
+
* unparseable value the same as a missing one so the caller can ignore
|
|
201
|
+
* it instead of firing a broken criterion.
|
|
202
|
+
*/
|
|
203
|
+
if (Number.isNaN(threshold as number)) {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
|
|
195
207
|
return threshold as number;
|
|
196
208
|
}
|
|
197
209
|
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { describe, expect, test } from "@jest/globals";
|
|
5
|
+
import RunnerInstallInstructions from "../../../../App/FeatureSet/Dashboard/src/Components/Runner/InstallInstructions";
|
|
6
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* The setup card renders the docker run command with the Runner's secret key
|
|
10
|
+
* embedded in it. Reading that key is restricted to Project Owner, Project
|
|
11
|
+
* Admin and Runbook Admin (see the ColumnAccessControl on Runner.key), so for
|
|
12
|
+
* a Member or a Viewer the field simply is not in the API response.
|
|
13
|
+
*
|
|
14
|
+
* The Runners table asked for the key and passed `(item.key as string) || ""`
|
|
15
|
+
* straight through, so those users were handed a command ending in
|
|
16
|
+
* ONEUPTIME_RUNNER_KEY= with nothing after it — which copies cleanly, runs on
|
|
17
|
+
* the host, and fails there with an authentication error. These tests pin that
|
|
18
|
+
* no command is ever rendered without a key.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const RUNNER_ID: ObjectID = new ObjectID("abc-123");
|
|
22
|
+
|
|
23
|
+
type RenderWithKeyFunction = (key: string) => HTMLElement;
|
|
24
|
+
|
|
25
|
+
const renderWithKey: RenderWithKeyFunction = (key: string): HTMLElement => {
|
|
26
|
+
const { container } = render(
|
|
27
|
+
<RunnerInstallInstructions runnerId={RUNNER_ID} runnerKey={key} />,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
return container;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe("RunnerInstallInstructions", () => {
|
|
34
|
+
describe("when the key is readable", () => {
|
|
35
|
+
test("renders the docker run command", () => {
|
|
36
|
+
const container: HTMLElement = renderWithKey("super-secret-key");
|
|
37
|
+
|
|
38
|
+
expect(container.textContent).toContain("docker run");
|
|
39
|
+
expect(container.textContent).toContain("oneuptime/runner:release");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("embeds the Runner id and key", () => {
|
|
43
|
+
const container: HTMLElement = renderWithKey("super-secret-key");
|
|
44
|
+
|
|
45
|
+
expect(container.textContent).toContain("ONEUPTIME_RUNNER_ID=abc-123");
|
|
46
|
+
expect(container.textContent).toContain(
|
|
47
|
+
"ONEUPTIME_RUNNER_KEY=super-secret-key",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("does not render the permission warning", () => {
|
|
52
|
+
renderWithKey("super-secret-key");
|
|
53
|
+
|
|
54
|
+
expect(
|
|
55
|
+
screen.queryByText(/do not have permission/i),
|
|
56
|
+
).not.toBeInTheDocument();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/*
|
|
60
|
+
* The container needs outbound HTTPS only; saying so here is what stops
|
|
61
|
+
* someone opening an inbound hole for it.
|
|
62
|
+
*/
|
|
63
|
+
test("states that the Runner needs no inbound connections", () => {
|
|
64
|
+
const container: HTMLElement = renderWithKey("super-secret-key");
|
|
65
|
+
|
|
66
|
+
expect(container.textContent).toContain(
|
|
67
|
+
"does not accept inbound connections",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
/*
|
|
72
|
+
* Added because "I ran the command and it still says Never connected" is
|
|
73
|
+
* the obvious next confusion once the never-connected state exists.
|
|
74
|
+
*/
|
|
75
|
+
test("explains that the first heartbeat takes up to a minute", () => {
|
|
76
|
+
const container: HTMLElement = renderWithKey("super-secret-key");
|
|
77
|
+
|
|
78
|
+
expect(container.textContent).toContain("every 60 seconds");
|
|
79
|
+
expect(container.textContent).toContain("Connected");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("when the key is not readable", () => {
|
|
84
|
+
test("renders the permission warning instead", () => {
|
|
85
|
+
renderWithKey("");
|
|
86
|
+
|
|
87
|
+
expect(
|
|
88
|
+
screen.getByText(
|
|
89
|
+
"You do not have permission to view this Runner's key",
|
|
90
|
+
),
|
|
91
|
+
).toBeInTheDocument();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/*
|
|
95
|
+
* The load-bearing assertion: no command at all, rather than a command
|
|
96
|
+
* that is silently broken.
|
|
97
|
+
*/
|
|
98
|
+
test("renders no docker command at all", () => {
|
|
99
|
+
const container: HTMLElement = renderWithKey("");
|
|
100
|
+
|
|
101
|
+
expect(container.textContent).not.toContain("docker run");
|
|
102
|
+
expect(container.textContent).not.toContain("ONEUPTIME_RUNNER_KEY");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("never emits an empty ONEUPTIME_RUNNER_KEY=", () => {
|
|
106
|
+
const container: HTMLElement = renderWithKey("");
|
|
107
|
+
|
|
108
|
+
expect(container.textContent).not.toMatch(/ONEUPTIME_RUNNER_KEY=\s/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("says who can supply the command", () => {
|
|
112
|
+
const container: HTMLElement = renderWithKey("");
|
|
113
|
+
|
|
114
|
+
expect(container.textContent).toContain("Project Owner");
|
|
115
|
+
expect(container.textContent).toContain("Project Admin");
|
|
116
|
+
expect(container.textContent).toContain("Runbook Admin");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import "@testing-library/jest-dom";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { describe, expect, test } from "@jest/globals";
|
|
5
|
+
import RunnerStatusElement from "../../../../App/FeatureSet/Dashboard/src/Components/Runner/RunnerStatus";
|
|
6
|
+
import Runner, {
|
|
7
|
+
RunnerConnectionStatus,
|
|
8
|
+
} from "../../../Models/DatabaseModels/Runner";
|
|
9
|
+
import OneUptimeDate from "../../../Types/Date";
|
|
10
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* The one component every Runner connection indicator in the dashboard now
|
|
14
|
+
* renders through. Three call sites used to hand-roll this, and all three read
|
|
15
|
+
* the persisted connectionStatus column — which is written on create and on
|
|
16
|
+
* the first heartbeat and never again. A Runner that died months ago still has
|
|
17
|
+
* it set to "connected".
|
|
18
|
+
*
|
|
19
|
+
* So the assertions that matter most here are the ones proving the rendered
|
|
20
|
+
* status tracks lastAlive and IGNORES connectionStatus, in both directions.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type RenderRunnerFunction = (
|
|
24
|
+
runner: Runner | JSONObject,
|
|
25
|
+
showLastSeen?: boolean,
|
|
26
|
+
) => void;
|
|
27
|
+
|
|
28
|
+
const renderRunner: RenderRunnerFunction = (
|
|
29
|
+
runner: Runner | JSONObject,
|
|
30
|
+
showLastSeen?: boolean,
|
|
31
|
+
): void => {
|
|
32
|
+
render(
|
|
33
|
+
showLastSeen === undefined ? (
|
|
34
|
+
<RunnerStatusElement runner={runner} />
|
|
35
|
+
) : (
|
|
36
|
+
<RunnerStatusElement runner={runner} showLastSeen={showLastSeen} />
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
type MinutesAgoFunction = (minutes: number) => Date;
|
|
42
|
+
|
|
43
|
+
const minutesAgo: MinutesAgoFunction = (minutes: number): Date => {
|
|
44
|
+
return OneUptimeDate.getSomeMinutesAgo(minutes);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
type StatusTextFunction = () => string;
|
|
48
|
+
|
|
49
|
+
/*
|
|
50
|
+
* Statusbubble renders role="status" with aria-label "Status: <text>", so the
|
|
51
|
+
* accessible name is the assertion surface — it is what a screen reader
|
|
52
|
+
* announces, not just what happens to be in a div.
|
|
53
|
+
*/
|
|
54
|
+
const statusText: StatusTextFunction = (): string => {
|
|
55
|
+
return screen.getByRole("status").getAttribute("aria-label") || "";
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe("RunnerStatusElement", () => {
|
|
59
|
+
describe("never connected", () => {
|
|
60
|
+
test("a Runner with no lastAlive reads 'Never connected'", () => {
|
|
61
|
+
renderRunner({ lastAlive: null });
|
|
62
|
+
|
|
63
|
+
expect(screen.getByText("Never connected")).toBeInTheDocument();
|
|
64
|
+
expect(statusText()).toBe("Status: Never connected");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("a Runner with lastAlive absent entirely reads 'Never connected'", () => {
|
|
68
|
+
renderRunner({});
|
|
69
|
+
|
|
70
|
+
expect(screen.getByText("Never connected")).toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/*
|
|
74
|
+
* The whole point of the third state. A Runner created ten seconds ago
|
|
75
|
+
* whose container has not been started is not a failure, and must not be
|
|
76
|
+
* dressed as one — it used to render the same red "Disconnected" as a
|
|
77
|
+
* Runner that crashed in production.
|
|
78
|
+
*/
|
|
79
|
+
test("it is not called Disconnected", () => {
|
|
80
|
+
renderRunner({ lastAlive: null });
|
|
81
|
+
|
|
82
|
+
expect(screen.queryByText("Disconnected")).not.toBeInTheDocument();
|
|
83
|
+
expect(statusText()).not.toContain("Disconnected");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("it is grey, not red", () => {
|
|
87
|
+
const { container } = render(
|
|
88
|
+
<RunnerStatusElement runner={{ lastAlive: null }} />,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const html: string = container.innerHTML;
|
|
92
|
+
|
|
93
|
+
// Gray500 #6b7280, Red #fd625e.
|
|
94
|
+
expect(html).toContain("107, 114, 128");
|
|
95
|
+
expect(html).not.toContain("253, 98, 94");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("it does not animate", () => {
|
|
99
|
+
const { container } = render(
|
|
100
|
+
<RunnerStatusElement runner={{ lastAlive: null }} />,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(container.innerHTML).not.toContain("animate-ping");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("connected", () => {
|
|
108
|
+
test("a heartbeat right now reads 'Connected'", () => {
|
|
109
|
+
renderRunner({ lastAlive: OneUptimeDate.getCurrentDate() });
|
|
110
|
+
|
|
111
|
+
expect(screen.getByText("Connected")).toBeInTheDocument();
|
|
112
|
+
expect(statusText()).toBe("Status: Connected");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("a heartbeat four minutes ago still reads 'Connected'", () => {
|
|
116
|
+
renderRunner({ lastAlive: minutesAgo(4) });
|
|
117
|
+
|
|
118
|
+
expect(screen.getByText("Connected")).toBeInTheDocument();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("it is green and animating", () => {
|
|
122
|
+
const { container } = render(
|
|
123
|
+
<RunnerStatusElement
|
|
124
|
+
runner={{ lastAlive: OneUptimeDate.getCurrentDate() }}
|
|
125
|
+
/>,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
// Green #2ab57d.
|
|
129
|
+
expect(container.innerHTML).toContain("42, 181, 125");
|
|
130
|
+
expect(container.innerHTML).toContain("animate-ping");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("disconnected", () => {
|
|
135
|
+
test("a heartbeat an hour ago reads 'Disconnected'", () => {
|
|
136
|
+
renderRunner({ lastAlive: minutesAgo(60) });
|
|
137
|
+
|
|
138
|
+
expect(statusText()).toContain("Disconnected");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/*
|
|
142
|
+
* "How long has it been gone" is the entire question when a Runner drops,
|
|
143
|
+
* and the detail card has no other column carrying it.
|
|
144
|
+
*/
|
|
145
|
+
test("it appends how long ago the last heartbeat was", () => {
|
|
146
|
+
renderRunner({ lastAlive: minutesAgo(60) });
|
|
147
|
+
|
|
148
|
+
expect(statusText()).toBe("Status: Disconnected · an hour ago");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a day-old heartbeat says a day ago", () => {
|
|
152
|
+
renderRunner({ lastAlive: minutesAgo(60 * 24) });
|
|
153
|
+
|
|
154
|
+
expect(statusText()).toBe("Status: Disconnected · a day ago");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
/*
|
|
158
|
+
* In the Runners table a Last Seen column sits directly beside this one,
|
|
159
|
+
* so repeating the relative time would be noise.
|
|
160
|
+
*/
|
|
161
|
+
test("showLastSeen={false} suppresses the relative time", () => {
|
|
162
|
+
renderRunner({ lastAlive: minutesAgo(60) }, false);
|
|
163
|
+
|
|
164
|
+
expect(statusText()).toBe("Status: Disconnected");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("showLastSeen={true} is the same as the default", () => {
|
|
168
|
+
renderRunner({ lastAlive: minutesAgo(60) }, true);
|
|
169
|
+
|
|
170
|
+
expect(statusText()).toBe("Status: Disconnected · an hour ago");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("it is red and does not animate", () => {
|
|
174
|
+
const { container } = render(
|
|
175
|
+
<RunnerStatusElement runner={{ lastAlive: minutesAgo(60) }} />,
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// Red #fd625e.
|
|
179
|
+
expect(container.innerHTML).toContain("253, 98, 94");
|
|
180
|
+
expect(container.innerHTML).not.toContain("animate-ping");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
/*
|
|
185
|
+
* The regression suite. Each of these is a shape the old inline
|
|
186
|
+
* implementations got wrong.
|
|
187
|
+
*/
|
|
188
|
+
describe("connectionStatus is ignored", () => {
|
|
189
|
+
test("stale lastAlive reads Disconnected even when connectionStatus says Connected", () => {
|
|
190
|
+
renderRunner({
|
|
191
|
+
connectionStatus: RunnerConnectionStatus.Connected,
|
|
192
|
+
lastAlive: minutesAgo(60 * 24 * 90),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(statusText()).toContain("Disconnected");
|
|
196
|
+
expect(screen.queryByText("Connected")).not.toBeInTheDocument();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("fresh lastAlive reads Connected even when connectionStatus says Disconnected", () => {
|
|
200
|
+
renderRunner({
|
|
201
|
+
connectionStatus: RunnerConnectionStatus.Disconnected,
|
|
202
|
+
lastAlive: OneUptimeDate.getCurrentDate(),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(statusText()).toBe("Status: Connected");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
/*
|
|
209
|
+
* Exactly what RunnerService.onBeforeCreate produces: connectionStatus
|
|
210
|
+
* defaulted to Disconnected, lastAlive still null. The user has not
|
|
211
|
+
* failed at anything yet — they have not run the Docker command.
|
|
212
|
+
*/
|
|
213
|
+
test("a freshly created Runner reads Never connected, not Disconnected", () => {
|
|
214
|
+
renderRunner({
|
|
215
|
+
connectionStatus: RunnerConnectionStatus.Disconnected,
|
|
216
|
+
lastAlive: null,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(statusText()).toBe("Status: Never connected");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("connectionStatus alone never produces a Connected bubble", () => {
|
|
223
|
+
renderRunner({ connectionStatus: RunnerConnectionStatus.Connected });
|
|
224
|
+
|
|
225
|
+
expect(statusText()).toBe("Status: Never connected");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("input shapes", () => {
|
|
230
|
+
/*
|
|
231
|
+
* The table hands over a hydrated model; ModelDetail's getElement hands
|
|
232
|
+
* over the same. The API layer hands over a plain object with an ISO
|
|
233
|
+
* string. All three have to work.
|
|
234
|
+
*/
|
|
235
|
+
test("accepts a hydrated Runner model", () => {
|
|
236
|
+
const runner: Runner = new Runner();
|
|
237
|
+
runner.lastAlive = OneUptimeDate.getCurrentDate();
|
|
238
|
+
|
|
239
|
+
renderRunner(runner);
|
|
240
|
+
|
|
241
|
+
expect(statusText()).toBe("Status: Connected");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("accepts a hydrated Runner model that never connected", () => {
|
|
245
|
+
renderRunner(new Runner());
|
|
246
|
+
|
|
247
|
+
expect(statusText()).toBe("Status: Never connected");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("accepts an ISO string lastAlive", () => {
|
|
251
|
+
renderRunner({ lastAlive: new Date().toISOString() });
|
|
252
|
+
|
|
253
|
+
expect(statusText()).toBe("Status: Connected");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("accepts a stale ISO string lastAlive", () => {
|
|
257
|
+
renderRunner({ lastAlive: minutesAgo(60).toISOString() });
|
|
258
|
+
|
|
259
|
+
expect(statusText()).toContain("Disconnected");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
/*
|
|
263
|
+
* An unparseable timestamp must not be rendered as a healthy Runner. It
|
|
264
|
+
* falls to Disconnected, and the appended relative time is suppressed
|
|
265
|
+
* rather than printed as "Invalid date".
|
|
266
|
+
*/
|
|
267
|
+
test("an unparseable lastAlive is Disconnected and prints no garbage time", () => {
|
|
268
|
+
renderRunner({ lastAlive: "not-a-date" });
|
|
269
|
+
|
|
270
|
+
expect(statusText()).toContain("Disconnected");
|
|
271
|
+
expect(statusText()).not.toContain("Invalid");
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|