@peers-app/peers-ui 0.26.4 → 0.26.5
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/dist/components/group-switcher.d.ts +6 -0
- package/dist/components/group-switcher.js +8 -1
- package/dist/components/markdown-with-mentions.d.ts +4 -0
- package/dist/components/markdown-with-mentions.js +6 -1
- package/dist/components/save-button.d.ts +15 -0
- package/dist/components/save-button.js +14 -3
- package/dist/globals.js +34 -8
- package/dist/host-shell/host-shell-consumer.d.ts +21 -0
- package/dist/host-shell/host-shell-consumer.js +50 -0
- package/dist/host-shell/host-shell-consumer.test.d.ts +1 -0
- package/dist/host-shell/host-shell-consumer.test.js +28 -0
- package/dist/mention-configs.js +2 -1
- package/dist/screens/groups/group-list.js +4 -1
- package/dist/screens/packages/package-details.d.ts +1 -0
- package/dist/screens/packages/package-details.js +27 -8
- package/dist/screens/packages/package-helpers.d.ts +3 -23
- package/dist/screens/packages/package-helpers.js +12 -46
- package/dist/screens/packages/package-helpers.test.d.ts +1 -0
- package/dist/screens/packages/package-helpers.test.js +53 -0
- package/dist/screens/packages/package-info.js +3 -2
- package/dist/screens/packages/package-list.d.ts +4 -2
- package/dist/screens/packages/package-list.js +50 -10
- package/dist/screens/packages/package-new-local.js +7 -4
- package/dist/screens/packages/package-runtime-status.d.ts +12 -0
- package/dist/screens/packages/package-runtime-status.js +33 -0
- package/dist/screens/packages/package-runtime-status.test.d.ts +1 -0
- package/dist/screens/packages/package-runtime-status.test.js +50 -0
- package/dist/screens/packages/package-versions.js +8 -55
- package/dist/screens/packages/packages-ipc.d.ts +41 -0
- package/dist/screens/packages/packages-ipc.js +102 -0
- package/dist/screens/packages/packages-ipc.test.d.ts +1 -0
- package/dist/screens/packages/packages-ipc.test.js +43 -0
- package/dist/screens/tools/tool-catalog-ipc.test.js +6 -0
- package/dist/screens/variables/variable-details.d.ts +1 -0
- package/dist/screens/variables/variable-details.js +11 -5
- package/dist/ui-router/ui-loader.js +10 -37
- package/package.json +3 -3
- package/src/components/group-switcher.tsx +12 -4
- package/src/components/markdown-with-mentions.tsx +7 -2
- package/src/components/save-button.tsx +24 -3
- package/src/globals.tsx +32 -11
- package/src/host-shell/host-shell-consumer.test.ts +32 -0
- package/src/host-shell/host-shell-consumer.ts +53 -0
- package/src/mention-configs.ts +2 -2
- package/src/screens/groups/group-list.tsx +6 -4
- package/src/screens/packages/package-details.tsx +31 -8
- package/src/screens/packages/package-helpers.test.ts +57 -0
- package/src/screens/packages/package-helpers.ts +13 -55
- package/src/screens/packages/package-info.tsx +3 -3
- package/src/screens/packages/package-list.tsx +51 -16
- package/src/screens/packages/package-new-local.tsx +8 -13
- package/src/screens/packages/package-runtime-status.test.tsx +58 -0
- package/src/screens/packages/package-runtime-status.tsx +68 -0
- package/src/screens/packages/package-versions.tsx +9 -72
- package/src/screens/packages/packages-ipc.test.ts +47 -0
- package/src/screens/packages/packages-ipc.ts +125 -0
- package/src/screens/tools/tool-catalog-ipc.test.ts +7 -1
- package/src/screens/variables/variable-details.tsx +11 -6
- package/src/ui-router/ui-loader.tsx +12 -51
|
@@ -5,11 +5,10 @@ import {
|
|
|
5
5
|
getUserRole,
|
|
6
6
|
hasDeviceFollowOverride,
|
|
7
7
|
type IPackageVersion,
|
|
8
|
-
Packages,
|
|
9
8
|
PackageVersions,
|
|
10
9
|
packagePrefsVar,
|
|
11
|
-
updatePackagePrefs,
|
|
12
10
|
} from "@peers-app/peers-sdk";
|
|
11
|
+
import { packagesTools } from "./packages-ipc";
|
|
13
12
|
|
|
14
13
|
/** Severity of a version update: major > minor > patch. */
|
|
15
14
|
export type UpdateLevel = "major" | "minor" | "patch" | null;
|
|
@@ -107,8 +106,9 @@ export async function isGroupAdmin(): Promise<boolean> {
|
|
|
107
106
|
/**
|
|
108
107
|
* Activate a specific package version, handling group-level vs device-local
|
|
109
108
|
* activation. When the device has no follow override and the user is a group
|
|
110
|
-
* admin, the group's `activePackageVersionId`
|
|
111
|
-
* stay in sync.
|
|
109
|
+
* admin, a release advances the group's `activePackageVersionId` so all devices
|
|
110
|
+
* stay in sync. Dev versions (including legacy missing or empty tags) and other
|
|
111
|
+
* selections only update the device-local prefs.
|
|
112
112
|
*
|
|
113
113
|
* @param packageId - The package to activate a version for.
|
|
114
114
|
* @param pv - The package version to activate.
|
|
@@ -122,57 +122,15 @@ export async function activatePackageVersion(
|
|
|
122
122
|
const devicePrefs = prefsVar();
|
|
123
123
|
|
|
124
124
|
const activatesGroup =
|
|
125
|
-
pv.versionTag
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
} else {
|
|
135
|
-
await updatePackagePrefs(packageId, { activePackageVersionId: pv.packageVersionId });
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* After promoting a version that is currently effective-active on this device,
|
|
141
|
-
* activate it when the new tag matches the group's auto-activate / follow rules.
|
|
142
|
-
*
|
|
143
|
-
* This closes the gotcha where a device is already running a `dev` build, promotes
|
|
144
|
-
* it to `beta`/`stable`, and expects the group active version to advance without a
|
|
145
|
-
* separate Activate click — but only when the group would accept that channel
|
|
146
|
-
* (e.g. default follow is `stable`; `beta` only when the group follows beta).
|
|
147
|
-
*
|
|
148
|
-
* @param packageId - Package that was promoted
|
|
149
|
-
* @param promotedPv - PV after the tag change (must include the new `versionTag`)
|
|
150
|
-
* @param opts.wasEffectiveActive - True when this PV was this device's effective active before promote
|
|
151
|
-
* @param opts.groupFollowTags - Group `IPackage.followVersionTags` (not device override)
|
|
152
|
-
* @param opts.groupPinned - True when group `versionFollowRange` is `pinned`
|
|
153
|
-
* @returns Whether activation was attempted
|
|
154
|
-
*/
|
|
155
|
-
export async function maybeActivateAfterPromote(
|
|
156
|
-
packageId: string,
|
|
157
|
-
promotedPv: IPackageVersion,
|
|
158
|
-
opts: {
|
|
159
|
-
wasEffectiveActive: boolean;
|
|
160
|
-
groupFollowTags: string | undefined;
|
|
161
|
-
groupPinned: boolean;
|
|
162
|
-
},
|
|
163
|
-
): Promise<boolean> {
|
|
164
|
-
if (!opts.wasEffectiveActive) return false;
|
|
165
|
-
if (opts.groupPinned) return false;
|
|
166
|
-
if (!promotedPv.versionTag || promotedPv.versionTag === "dev") return false;
|
|
167
|
-
|
|
168
|
-
// Docs default: groups follow `stable` when followVersionTags is unset.
|
|
169
|
-
const followTags = opts.groupFollowTags ?? "stable";
|
|
170
|
-
if (!doesTagMatch(undefined, promotedPv.versionTag, followTags, undefined)) {
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
await activatePackageVersion(packageId, promotedPv);
|
|
175
|
-
return true;
|
|
125
|
+
(pv.versionTag || "dev") !== "dev" &&
|
|
126
|
+
!hasDeviceFollowOverride(devicePrefs) &&
|
|
127
|
+
(await isGroupAdmin());
|
|
128
|
+
|
|
129
|
+
await (await packagesTools()).activatePackageVersion({
|
|
130
|
+
packageId,
|
|
131
|
+
packageVersionId: pv.packageVersionId,
|
|
132
|
+
scope: activatesGroup ? "group" : "device",
|
|
133
|
+
});
|
|
176
134
|
}
|
|
177
135
|
|
|
178
136
|
/** Numeric rank for sorting packages by update urgency. */
|
|
@@ -10,13 +10,13 @@ import {
|
|
|
10
10
|
PackageVersions,
|
|
11
11
|
packagePrefsVar,
|
|
12
12
|
packagesRootDir,
|
|
13
|
-
rpcServerCalls,
|
|
14
13
|
updatePackagePrefs,
|
|
15
14
|
} from "@peers-app/peers-sdk";
|
|
16
15
|
import { Input } from "../../components/input";
|
|
17
16
|
import { MarkdownWithMentions } from "../../components/markdown-with-mentions";
|
|
18
17
|
import { Tooltip } from "../../components/tooltip";
|
|
19
18
|
import { useObservable, usePromise } from "../../hooks";
|
|
19
|
+
import { openInEditorOnHost, openOnHost } from "../../host-shell/host-shell-consumer";
|
|
20
20
|
import { PackageNetworkPanel } from "./package-network-panel";
|
|
21
21
|
|
|
22
22
|
/** Info tab for the package details screen. */
|
|
@@ -115,7 +115,7 @@ export const PackageInfo = (props: { pkg: IDoc<IPackage> }) => {
|
|
|
115
115
|
<button
|
|
116
116
|
className="btn btn-sm btn-link"
|
|
117
117
|
onClick={() => {
|
|
118
|
-
|
|
118
|
+
void openInEditorOnHost(localPath || packagesRootDir);
|
|
119
119
|
}}
|
|
120
120
|
>
|
|
121
121
|
Open Local
|
|
@@ -142,7 +142,7 @@ export const PackageInfo = (props: { pkg: IDoc<IPackage> }) => {
|
|
|
142
142
|
<button
|
|
143
143
|
className="btn btn-sm btn-link"
|
|
144
144
|
onClick={() => {
|
|
145
|
-
|
|
145
|
+
void openOnHost(remoteRepoUrl);
|
|
146
146
|
}}
|
|
147
147
|
>
|
|
148
148
|
Open Remote
|
|
@@ -3,14 +3,13 @@ import {
|
|
|
3
3
|
type ICursorIterable,
|
|
4
4
|
type IPackage,
|
|
5
5
|
type IPackageVersion,
|
|
6
|
+
isid,
|
|
7
|
+
isPackageRepositoryUrl,
|
|
6
8
|
observable,
|
|
7
9
|
Packages,
|
|
8
10
|
PackageVersions,
|
|
9
11
|
packagePrefsVar,
|
|
10
12
|
packagesRootDir,
|
|
11
|
-
rpcServerCalls,
|
|
12
|
-
unwrapAddOrUpdatePackageResult,
|
|
13
|
-
updatePackagePrefs,
|
|
14
13
|
} from "@peers-app/peers-sdk";
|
|
15
14
|
import type React from "react";
|
|
16
15
|
import { useCallback, useEffect, useState } from "react";
|
|
@@ -20,6 +19,7 @@ import { LoadingIndicator } from "../../components/loading-indicator";
|
|
|
20
19
|
import { Tooltip } from "../../components/tooltip";
|
|
21
20
|
import { isDesktop, mainContentPath } from "../../globals";
|
|
22
21
|
import { useObservable, useObservableState, usePromise } from "../../hooks";
|
|
22
|
+
import { openOnHost } from "../../host-shell/host-shell-consumer";
|
|
23
23
|
import { registerInternalPeersUI } from "../../ui-router/ui-loader";
|
|
24
24
|
import { LocalFolderGrantsPanel } from "./local-folder-grants-panel";
|
|
25
25
|
import {
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
type UpdateLevel,
|
|
30
30
|
updateLevelRank,
|
|
31
31
|
} from "./package-helpers";
|
|
32
|
+
import { PackageRuntimeBadge } from "./package-runtime-status";
|
|
33
|
+
import { packagesTools } from "./packages-ipc";
|
|
32
34
|
|
|
33
35
|
interface IPackageRowStatus extends IVersionStatus {
|
|
34
36
|
/** When on a dev version, the group's non-dev active PV (if any). */
|
|
@@ -53,6 +55,17 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
|
|
|
53
55
|
const [updating, setUpdating] = useState(false);
|
|
54
56
|
const [devicePrefs] = useObservable(packagePrefsVar(pkg.packageId));
|
|
55
57
|
const effective = getEffectivePackagePrefs(pkg, devicePrefs);
|
|
58
|
+
// A dev reload rewrites the selected version row in place (same id, new version), so the
|
|
59
|
+
// ids below do not change; recompute when this package's version rows change.
|
|
60
|
+
const [versionsRevision, setVersionsRevision] = useState(0);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const sub = PackageVersions().dataChanged.subscribe((evt) => {
|
|
63
|
+
if (evt.dataObject?.packageId === pkg.packageId) setVersionsRevision((value) => value + 1);
|
|
64
|
+
});
|
|
65
|
+
return () => {
|
|
66
|
+
sub.unsubscribe();
|
|
67
|
+
};
|
|
68
|
+
}, [pkg.packageId]);
|
|
56
69
|
|
|
57
70
|
const status = usePromise(
|
|
58
71
|
async (): Promise<IPackageRowStatus | null> => {
|
|
@@ -94,6 +107,7 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
|
|
|
94
107
|
pkg.packageId,
|
|
95
108
|
effective.followTags,
|
|
96
109
|
pkg.activePackageVersionId,
|
|
110
|
+
versionsRevision,
|
|
97
111
|
],
|
|
98
112
|
);
|
|
99
113
|
|
|
@@ -122,7 +136,10 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
|
|
|
122
136
|
async function handleUseRelease() {
|
|
123
137
|
setUpdating(true);
|
|
124
138
|
try {
|
|
125
|
-
await
|
|
139
|
+
await (await packagesTools()).activatePackageVersion({
|
|
140
|
+
packageId: pkg.packageId,
|
|
141
|
+
scope: "device",
|
|
142
|
+
});
|
|
126
143
|
onUpdated();
|
|
127
144
|
} catch (err: unknown) {
|
|
128
145
|
alert(`Failed to switch: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -135,8 +152,10 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
|
|
|
135
152
|
if (!status?.latestDevPv) return;
|
|
136
153
|
setUpdating(true);
|
|
137
154
|
try {
|
|
138
|
-
await
|
|
139
|
-
|
|
155
|
+
await (await packagesTools()).activatePackageVersion({
|
|
156
|
+
packageId: pkg.packageId,
|
|
157
|
+
packageVersionId: status.latestDevPv.packageVersionId,
|
|
158
|
+
scope: "device",
|
|
140
159
|
});
|
|
141
160
|
onUpdated();
|
|
142
161
|
} catch (err: unknown) {
|
|
@@ -151,6 +170,9 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
|
|
|
151
170
|
<div className="flex-grow-1" style={{ minWidth: 0 }}>
|
|
152
171
|
<i className="bi bi-box-fill"></i>
|
|
153
172
|
<a href={`#packages/${pkg.packageId}`}>{pkg.name}</a>
|
|
173
|
+
<span className="ms-2">
|
|
174
|
+
<PackageRuntimeBadge packageId={pkg.packageId} />
|
|
175
|
+
</span>
|
|
154
176
|
{isDeviceOverridden && (
|
|
155
177
|
<span
|
|
156
178
|
className="text-warning ms-1"
|
|
@@ -236,8 +258,10 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
|
|
|
236
258
|
);
|
|
237
259
|
};
|
|
238
260
|
|
|
239
|
-
/**
|
|
240
|
-
|
|
261
|
+
/**
|
|
262
|
+
* Packages screen: search, create/import/install from the search box, local folder grants,
|
|
263
|
+
* and the installed package list.
|
|
264
|
+
*/
|
|
241
265
|
export const PackageList = () => {
|
|
242
266
|
const [searchTextObs] = useState(() => observable(""));
|
|
243
267
|
const [searchText] = useObservable(searchTextObs);
|
|
@@ -323,13 +347,11 @@ export const PackageList = () => {
|
|
|
323
347
|
const name = searchText.trim();
|
|
324
348
|
if (!name) return;
|
|
325
349
|
|
|
326
|
-
if (
|
|
350
|
+
if (isPackageRepositoryUrl(name)) {
|
|
327
351
|
if (!confirm(`Add remote package: ${name}`)) return;
|
|
328
352
|
try {
|
|
329
353
|
addingPackage(true);
|
|
330
|
-
const { packageId } =
|
|
331
|
-
await rpcServerCalls.addOrUpdatePackage(name),
|
|
332
|
-
);
|
|
354
|
+
const { packageId } = await (await packagesTools()).clonePackage({ url: name });
|
|
333
355
|
const pkg = await Packages().get(packageId);
|
|
334
356
|
if (pkg) {
|
|
335
357
|
mainContentPath(`packages/${packageId}`);
|
|
@@ -344,13 +366,26 @@ export const PackageList = () => {
|
|
|
344
366
|
return;
|
|
345
367
|
}
|
|
346
368
|
|
|
369
|
+
// A package id re-enables that package, or copies it here from another of your contexts.
|
|
370
|
+
if (isid(name)) {
|
|
371
|
+
if (!confirm(`Install package ${name} into this context`)) return;
|
|
372
|
+
try {
|
|
373
|
+
addingPackage(true);
|
|
374
|
+
const { packageId } = await (await packagesTools()).installPackage({ packageId: name });
|
|
375
|
+
mainContentPath(`packages/${packageId}`);
|
|
376
|
+
} catch (err: unknown) {
|
|
377
|
+
confirm(`Error installing package: ${err instanceof Error ? err.message : String(err)}`);
|
|
378
|
+
} finally {
|
|
379
|
+
addingPackage(false);
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
347
384
|
if (!confirm(`Create or import local package: ${searchText}`)) return;
|
|
348
385
|
|
|
349
386
|
try {
|
|
350
387
|
addingPackage(true);
|
|
351
|
-
const { packageId, warning } =
|
|
352
|
-
await rpcServerCalls.addOrUpdatePackage(name),
|
|
353
|
-
);
|
|
388
|
+
const { packageId, warning } = await (await packagesTools()).createPackage({ name });
|
|
354
389
|
const pkg = await Packages().get(packageId);
|
|
355
390
|
if (pkg) {
|
|
356
391
|
if (warning) {
|
|
@@ -430,7 +465,7 @@ export const PackageList = () => {
|
|
|
430
465
|
<li>
|
|
431
466
|
<a
|
|
432
467
|
className="dropdown-item"
|
|
433
|
-
onClick={() =>
|
|
468
|
+
onClick={() => void openOnHost(packagesRootDir)}
|
|
434
469
|
style={{ cursor: "pointer" }}
|
|
435
470
|
>
|
|
436
471
|
Open Packages Directory
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
camelCaseToHyphens,
|
|
3
|
-
Packages,
|
|
4
|
-
packagesRootDir,
|
|
5
|
-
rpcServerCalls,
|
|
6
|
-
unwrapAddOrUpdatePackageResult,
|
|
7
|
-
} from "@peers-app/peers-sdk";
|
|
1
|
+
import { camelCaseToHyphens, Packages, packagesRootDir } from "@peers-app/peers-sdk";
|
|
8
2
|
import React, { useState } from "react";
|
|
9
3
|
import { mainContentPath } from "../../globals";
|
|
4
|
+
import { openInEditorOnHost } from "../../host-shell/host-shell-consumer";
|
|
5
|
+
import { packagesTools } from "./packages-ipc";
|
|
10
6
|
|
|
11
7
|
/** Form for scaffolding a new local package from the template into `~/peers/packages`. */
|
|
12
8
|
export const PackageNewLocal = () => {
|
|
@@ -35,18 +31,17 @@ export const PackageNewLocal = () => {
|
|
|
35
31
|
|
|
36
32
|
const createLocalPackage = async () => {
|
|
37
33
|
try {
|
|
38
|
-
const { packageId, warning } =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
);
|
|
34
|
+
const { packageId, warning } = await (await packagesTools()).createPackage({
|
|
35
|
+
name: packageName,
|
|
36
|
+
location: packageLocation || packageLocationDefault,
|
|
37
|
+
});
|
|
43
38
|
const pkg = await Packages().get(packageId);
|
|
44
39
|
if (pkg) {
|
|
45
40
|
if (warning) {
|
|
46
41
|
confirm(warning);
|
|
47
42
|
}
|
|
48
43
|
mainContentPath(`packages/${packageId}`);
|
|
49
|
-
|
|
44
|
+
void openInEditorOnHost(packageLocation || packageLocationDefault);
|
|
50
45
|
}
|
|
51
46
|
} catch (err) {
|
|
52
47
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { IPackageRuntimeState } from "@peers-app/peers-sdk";
|
|
2
|
+
import { render } from "@testing-library/react";
|
|
3
|
+
import { PackageRuntimeBadge, PackageRuntimeStatus } from "./package-runtime-status";
|
|
4
|
+
|
|
5
|
+
let mockStates: Record<string, IPackageRuntimeState> | undefined;
|
|
6
|
+
jest.mock("./packages-ipc", () => ({
|
|
7
|
+
...jest.requireActual("./packages-ipc"),
|
|
8
|
+
usePackageRuntimeStates: () => mockStates,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const packageId = "pkg";
|
|
12
|
+
|
|
13
|
+
describe("PackageRuntimeStatus", () => {
|
|
14
|
+
it("renders nothing until the host reports state", () => {
|
|
15
|
+
mockStates = undefined;
|
|
16
|
+
expect(render(<PackageRuntimeStatus packageId={packageId} />).container).toBeEmptyDOMElement();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("shows the running version and a failed replacement's error", () => {
|
|
20
|
+
mockStates = {
|
|
21
|
+
[packageId]: {
|
|
22
|
+
packageId,
|
|
23
|
+
status: "failed",
|
|
24
|
+
running: { kind: "isolated", version: "1.0.0", versionTag: "dev" },
|
|
25
|
+
lastError: { phase: "load", message: "manifest has unknown key", version: "1.0.1", at: 1 },
|
|
26
|
+
updatedAt: 1,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
const view = render(<PackageRuntimeStatus packageId={packageId} />);
|
|
30
|
+
expect(view.container).toHaveTextContent("Update failed");
|
|
31
|
+
expect(view.container).toHaveTextContent("Running v1.0.0 (dev) on this device");
|
|
32
|
+
expect(view.getByRole("alert")).toHaveTextContent(
|
|
33
|
+
"Loading v1.0.1 failed: manifest has unknown key",
|
|
34
|
+
);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("shows a worker failure and a reason when nothing is running", () => {
|
|
38
|
+
mockStates = {
|
|
39
|
+
[packageId]: {
|
|
40
|
+
packageId,
|
|
41
|
+
status: "unavailable",
|
|
42
|
+
reason: "The selected version's bundle file is not on this device yet",
|
|
43
|
+
worker: { workerRunning: false, lastWorkerError: { message: "boom", at: 1 } },
|
|
44
|
+
updatedAt: 1,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const view = render(<PackageRuntimeStatus packageId={packageId} />);
|
|
48
|
+
expect(view.container).toHaveTextContent("not on this device yet");
|
|
49
|
+
expect(view.getByRole("alert")).toHaveTextContent("The package worker failed: boom");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("renders a running badge", () => {
|
|
53
|
+
mockStates = { [packageId]: { packageId, status: "running", updatedAt: 1 } };
|
|
54
|
+
expect(render(<PackageRuntimeBadge packageId={packageId} />).container).toHaveTextContent(
|
|
55
|
+
"Running",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import type { IPackageRuntimeState } from "@peers-app/peers-sdk";
|
|
2
|
+
import { packageStatusBadge, usePackageRuntimeStates } from "./packages-ipc";
|
|
3
|
+
|
|
4
|
+
interface IPackageRuntimeProps {
|
|
5
|
+
/** Package to show. */
|
|
6
|
+
packageId: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Small badge with a package's runtime status on this device; renders nothing until known. */
|
|
10
|
+
export const PackageRuntimeBadge = ({ packageId }: IPackageRuntimeProps) => {
|
|
11
|
+
const states = usePackageRuntimeStates();
|
|
12
|
+
return <RuntimeBadge state={states?.[packageId]} />;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Runtime status on this device: badge, running version, and the last load or worker
|
|
17
|
+
* failure.
|
|
18
|
+
*/
|
|
19
|
+
export const PackageRuntimeStatus = ({ packageId }: IPackageRuntimeProps) => {
|
|
20
|
+
const state = usePackageRuntimeStates()?.[packageId];
|
|
21
|
+
if (!state) return null;
|
|
22
|
+
const running = state.running;
|
|
23
|
+
const workerError = state.worker?.lastWorkerError;
|
|
24
|
+
return (
|
|
25
|
+
<div className="mb-3" data-testid="package-runtime-status">
|
|
26
|
+
<div className="d-flex align-items-center gap-2">
|
|
27
|
+
<RuntimeBadge state={state} />
|
|
28
|
+
{running?.version ? (
|
|
29
|
+
<span className="text-muted small">
|
|
30
|
+
Running v{running.version}
|
|
31
|
+
{running.versionTag ? ` (${running.versionTag})` : ""} on this device
|
|
32
|
+
</span>
|
|
33
|
+
) : state.reason ? (
|
|
34
|
+
<span className="text-muted small">{state.reason}</span>
|
|
35
|
+
) : null}
|
|
36
|
+
</div>
|
|
37
|
+
{state.status === "failed" && state.lastError ? (
|
|
38
|
+
<RuntimeError
|
|
39
|
+
title={
|
|
40
|
+
state.lastError.version
|
|
41
|
+
? `Loading v${state.lastError.version} failed`
|
|
42
|
+
: "Loading this package failed"
|
|
43
|
+
}
|
|
44
|
+
message={state.lastError.message}
|
|
45
|
+
/>
|
|
46
|
+
) : null}
|
|
47
|
+
{workerError ? (
|
|
48
|
+
<RuntimeError title="The package worker failed" message={workerError.message} />
|
|
49
|
+
) : null}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const RuntimeBadge = ({ state }: { state: IPackageRuntimeState | undefined }) => {
|
|
55
|
+
const badge = packageStatusBadge(state);
|
|
56
|
+
if (!badge) return null;
|
|
57
|
+
return (
|
|
58
|
+
<span className={`badge text-bg-${badge.color}`} title={state?.lastError?.message}>
|
|
59
|
+
{badge.label}
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const RuntimeError = ({ title, message }: { title: string; message: string }) => (
|
|
65
|
+
<div className="alert alert-danger py-2 px-3 mt-2 mb-0 small" role="alert">
|
|
66
|
+
<strong>{title}:</strong> <span className="font-monospace">{message}</span>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
-
computePackageVersionHash,
|
|
3
2
|
getEffectivePackagePrefs,
|
|
4
|
-
getMe,
|
|
5
3
|
hasDeviceFollowOverride,
|
|
6
4
|
type IDoc,
|
|
7
5
|
type IPackage,
|
|
@@ -9,13 +7,14 @@ import {
|
|
|
9
7
|
Packages,
|
|
10
8
|
PackageVersions,
|
|
11
9
|
packagePrefsVar,
|
|
12
|
-
|
|
10
|
+
packageVersionPromotionTargets,
|
|
13
11
|
Users,
|
|
14
12
|
updatePackagePrefs,
|
|
15
13
|
} from "@peers-app/peers-sdk";
|
|
16
14
|
import { useState } from "react";
|
|
17
15
|
import { useObservable, useObservableState, usePromise } from "../../hooks";
|
|
18
|
-
import { activatePackageVersion, isGroupAdmin
|
|
16
|
+
import { activatePackageVersion, isGroupAdmin } from "./package-helpers";
|
|
17
|
+
import { packagesTools } from "./packages-ipc";
|
|
19
18
|
|
|
20
19
|
function formatDate(iso: string): string {
|
|
21
20
|
try {
|
|
@@ -113,61 +112,14 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
|
|
|
113
112
|
|
|
114
113
|
async function promoteVersion(pv: IPackageVersion, newTag: string) {
|
|
115
114
|
try {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
action: `promoted:${newTag}`,
|
|
120
|
-
by: me.userId,
|
|
121
|
-
at: new Date().toISOString(),
|
|
122
|
-
signature: "",
|
|
123
|
-
});
|
|
124
|
-
const pvHash = computePackageVersionHash(
|
|
125
|
-
pv.version,
|
|
126
|
-
newTag,
|
|
127
|
-
pv.packageBundleFileHash,
|
|
128
|
-
pv.routesBundleFileHash,
|
|
129
|
-
pv.uiBundleFileHash,
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
// Auto-sign with package author key if available (server/PWA host — key never
|
|
133
|
-
// exposed to UI). Soft-fail when the key is missing so group admins can still
|
|
134
|
-
// promote, but never keep a signature that was computed for the old tag.
|
|
135
|
-
const authorSig = await rpcServerCalls.signPackageAuthorVersion({
|
|
136
|
-
packageId: pv.packageId,
|
|
115
|
+
// The host re-tags, re-signs, records history, and activates the version when this
|
|
116
|
+
// device was running it and the group follows the new tag.
|
|
117
|
+
const result = await (await packagesTools()).promotePackageVersion({
|
|
137
118
|
packageVersionId: pv.packageVersionId,
|
|
138
|
-
version: pv.version,
|
|
139
119
|
versionTag: newTag,
|
|
140
|
-
packageBundleFileHash: pv.packageBundleFileHash,
|
|
141
|
-
routesBundleFileHash: pv.routesBundleFileHash,
|
|
142
|
-
uiBundleFileHash: pv.uiBundleFileHash,
|
|
143
|
-
appNavs: pv.appNavs,
|
|
144
|
-
createdBy: pv.createdBy,
|
|
145
|
-
createdAt: pv.createdAt,
|
|
146
120
|
});
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
...pv,
|
|
150
|
-
versionTag: newTag,
|
|
151
|
-
packageVersionHash: pvHash,
|
|
152
|
-
history,
|
|
153
|
-
packageAuthorSignature: authorSig,
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
|
|
157
|
-
|
|
158
|
-
// Promoting the version this device is already running should advance the
|
|
159
|
-
// group active version when the new tag matches group follow policy
|
|
160
|
-
// (e.g. promote active → stable with default follow settings).
|
|
161
|
-
const pkgJs = pkg.toJS();
|
|
162
|
-
const activated = await maybeActivateAfterPromote(pkg.packageId, updated, {
|
|
163
|
-
wasEffectiveActive: pv.packageVersionId === effectiveActiveVersionId,
|
|
164
|
-
groupFollowTags: pkgJs.followVersionTags,
|
|
165
|
-
groupPinned: pkgJs.versionFollowRange === "pinned",
|
|
166
|
-
});
|
|
167
|
-
if (activated) {
|
|
168
|
-
await pkg.load();
|
|
169
|
-
}
|
|
170
|
-
|
|
121
|
+
if (result.warning) alert(result.warning);
|
|
122
|
+
if (result.activated) await pkg.load();
|
|
171
123
|
refreshKey(refreshKey() + 1);
|
|
172
124
|
} catch (err: unknown) {
|
|
173
125
|
alert(`Failed to promote version: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -242,21 +194,6 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
|
|
|
242
194
|
);
|
|
243
195
|
};
|
|
244
196
|
|
|
245
|
-
/**
|
|
246
|
-
* Valid promotion targets for each version tag. Promotion only moves forward:
|
|
247
|
-
* dev -> beta -> stable. No demotions allowed.
|
|
248
|
-
*/
|
|
249
|
-
function getPromoteTargets(currentTag?: string): string[] {
|
|
250
|
-
switch (currentTag) {
|
|
251
|
-
case "dev":
|
|
252
|
-
return ["beta", "stable"];
|
|
253
|
-
case "beta":
|
|
254
|
-
return ["stable"];
|
|
255
|
-
default:
|
|
256
|
-
return [];
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
197
|
function VersionRow(props: {
|
|
261
198
|
pv: IPackageVersion;
|
|
262
199
|
isActive: boolean;
|
|
@@ -273,7 +210,7 @@ function VersionRow(props: {
|
|
|
273
210
|
const { pv, isActive, isPinned, pinning, activating, deleting, userMap } = props;
|
|
274
211
|
const creatorName = userMap.get(pv.createdBy);
|
|
275
212
|
const currentTag = pv.versionTag || "dev";
|
|
276
|
-
const promoteTargets =
|
|
213
|
+
const promoteTargets = packageVersionPromotionTargets(currentTag);
|
|
277
214
|
|
|
278
215
|
return (
|
|
279
216
|
<div className={`list-group-item ${isActive ? "list-group-item-success" : ""}`}>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const hostContractConsumer = jest.fn(
|
|
2
|
+
(_definition: unknown, options: { dataContextId: () => Promise<string> }) => ({
|
|
3
|
+
get: async () => ({ tools: { boundTo: await options.dataContextId() } }),
|
|
4
|
+
dispose: jest.fn(),
|
|
5
|
+
}),
|
|
6
|
+
);
|
|
7
|
+
jest.mock("@peers-app/peers-sdk", () => ({
|
|
8
|
+
getDefaultDataContext: async () => ({ dataContextId: "active-group" }),
|
|
9
|
+
getPackagesContractDefinition: () => ({ name: "System Packages" }),
|
|
10
|
+
hostContractConsumer: (definition: unknown, options: { dataContextId: () => Promise<string> }) =>
|
|
11
|
+
hostContractConsumer(definition, options),
|
|
12
|
+
subscribeHostContractTransport: () => () => undefined,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { packageStatusBadge, packagesContractFor, packagesTools } from "./packages-ipc";
|
|
16
|
+
|
|
17
|
+
test("caches one host consumer per data context and defaults to the active group", async () => {
|
|
18
|
+
expect(packagesContractFor("group-a")).toBe(packagesContractFor("group-a"));
|
|
19
|
+
expect(packagesContractFor("group-b")).not.toBe(packagesContractFor("group-a"));
|
|
20
|
+
expect(await packagesTools("group-b")).toEqual({ boundTo: "group-b" });
|
|
21
|
+
expect(await packagesTools()).toEqual({ boundTo: "active-group" });
|
|
22
|
+
expect(hostContractConsumer).toHaveBeenCalledTimes(3);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test.each([
|
|
26
|
+
[undefined, undefined],
|
|
27
|
+
[{ status: "running" }, { label: "Running", color: "success" }],
|
|
28
|
+
[{ status: "failed" }, { label: "Failed", color: "danger" }],
|
|
29
|
+
[
|
|
30
|
+
{ status: "failed", running: { kind: "isolated", version: "1.0.0" } },
|
|
31
|
+
{ label: "Update failed", color: "warning" },
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
{
|
|
35
|
+
status: "running",
|
|
36
|
+
worker: { workerRunning: false, lastWorkerError: { message: "x", at: 1 } },
|
|
37
|
+
},
|
|
38
|
+
{ label: "Worker failed", color: "danger" },
|
|
39
|
+
],
|
|
40
|
+
[{ status: "unavailable" }, { label: "Waiting for files", color: "warning" }],
|
|
41
|
+
[{ status: "unsupported" }, { label: "Unsupported here", color: "secondary" }],
|
|
42
|
+
[{ status: "disabled" }, { label: "Disabled", color: "secondary" }],
|
|
43
|
+
[{ status: "inactive" }, undefined],
|
|
44
|
+
[{ status: "notLoaded" }, undefined],
|
|
45
|
+
] as const)("badge for %p", (runtime, expected) => {
|
|
46
|
+
expect(packageStatusBadge(runtime as never)).toEqual(expected);
|
|
47
|
+
});
|