@bygghammar/cms-platform 0.93.0 → 0.94.0
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/CHANGELOG.md +67 -0
- package/package.json +2 -1
- package/src/admin/module-inventory.ts +67 -0
- package/src/admin/registry-metadata.ts +256 -0
- package/src/admin/semver.ts +111 -0
- package/src/admin/version-core.ts +233 -25
- package/src/admin/version-report.ts +295 -0
- package/src/backoffice/VersionView.tsx +747 -66
- package/src/backoffice/routes/index.tsx +94 -9
- package/src/backoffice/shell/use-resolved-config.tsx +30 -5
- package/src/config/modules.ts +11 -0
- package/src/popups/package-name.ts +8 -0
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
Users,
|
|
27
27
|
} from "lucide-react";
|
|
28
28
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
29
|
-
import { useCallback, useRef } from "react";
|
|
29
|
+
import { useCallback, useRef, useState } from "react";
|
|
30
30
|
import { useServerFn } from "@tanstack/react-start";
|
|
31
31
|
|
|
32
32
|
import { MISSING_CREATED_ID } from "../../tracking/injections";
|
|
@@ -48,13 +48,16 @@ import { SitemapView, type SitemapCheck } from "../SitemapView";
|
|
|
48
48
|
import { SiteSettingsView } from "../SiteSettingsView";
|
|
49
49
|
import { TrackingView, type TrackingDeps } from "../TrackingView";
|
|
50
50
|
import { UsersView } from "../UsersView";
|
|
51
|
-
import { VersionView } from "../VersionView";
|
|
51
|
+
import { VersionView, type PlatformBuildInfo, type PlatformRelease } from "../VersionView";
|
|
52
|
+
import { moduleInventory, type ModuleSettingsRead } from "../../admin/module-inventory";
|
|
53
|
+
import type { VersionReport } from "../../admin/version-report";
|
|
52
54
|
import { parseEffectiveAdminHost } from "../../site/locale-domains";
|
|
53
55
|
import {
|
|
54
56
|
useFeatureEnabled,
|
|
55
57
|
useFeatureGate,
|
|
56
58
|
useModuleGate,
|
|
57
59
|
useMyRoles,
|
|
60
|
+
useResolvedSiteConfig,
|
|
58
61
|
} from "../shell/use-resolved-config";
|
|
59
62
|
import { backofficeHost, backofficePaths } from "./host";
|
|
60
63
|
|
|
@@ -127,14 +130,96 @@ export function UsersRoute() {
|
|
|
127
130
|
);
|
|
128
131
|
}
|
|
129
132
|
|
|
130
|
-
|
|
133
|
+
type SystemVersionPayload = {
|
|
134
|
+
build?: PlatformBuildInfo;
|
|
135
|
+
releases?: PlatformRelease[];
|
|
136
|
+
/** Present from hosts that build it with `buildVersionReport`. */
|
|
137
|
+
report?: VersionReport;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const SYSTEM_VERSION_QUERY_KEY = ["admin", "system-version"] as const;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Release, package and module screen.
|
|
144
|
+
*
|
|
145
|
+
* Read-only: it shows the module switches, it never changes them. The version
|
|
146
|
+
* read is not polled — no refetch on focus, a five-minute stale time — and a
|
|
147
|
+
* manual check goes through the server, which applies its own minimum
|
|
148
|
+
* interval before asking the registry again.
|
|
149
|
+
*/
|
|
131
150
|
export function VersionRoute() {
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
const
|
|
151
|
+
const host = backofficeHost();
|
|
152
|
+
const paths = backofficePaths(host);
|
|
153
|
+
const fetchVersion = useServerFn(host.fns.getSystemVersion);
|
|
154
|
+
const queryClient = useQueryClient();
|
|
155
|
+
const q = useQuery({
|
|
156
|
+
queryKey: SYSTEM_VERSION_QUERY_KEY,
|
|
157
|
+
queryFn: () => fetchVersion({ data: { refresh: false } }) as Promise<SystemVersionPayload>,
|
|
158
|
+
staleTime: 5 * 60_000,
|
|
159
|
+
refetchOnWindowFocus: false,
|
|
160
|
+
retry: 1,
|
|
161
|
+
});
|
|
162
|
+
const refreshing = useRef(false);
|
|
163
|
+
const [isRefreshing, setRefreshing] = useState(false);
|
|
164
|
+
// The `dataUpdatedAt` of the answer that was on screen when a "Check again"
|
|
165
|
+
// failed. The failure notice shows while that same answer is still shown, so
|
|
166
|
+
// any later successful answer (a retry or a re-read) clears it.
|
|
167
|
+
const [refreshFailedFor, setRefreshFailedFor] = useState<number | null>(null);
|
|
168
|
+
const refresh = useCallback(async () => {
|
|
169
|
+
if (refreshing.current) return;
|
|
170
|
+
refreshing.current = true;
|
|
171
|
+
setRefreshing(true);
|
|
172
|
+
try {
|
|
173
|
+
const next = (await fetchVersion({ data: { refresh: true } })) as SystemVersionPayload;
|
|
174
|
+
queryClient.setQueryData(SYSTEM_VERSION_QUERY_KEY, next);
|
|
175
|
+
setRefreshFailedFor(null);
|
|
176
|
+
} catch {
|
|
177
|
+
// Keep the last answer on screen, but say the check failed. The error
|
|
178
|
+
// itself (an expired session, a network failure) is not shown.
|
|
179
|
+
setRefreshFailedFor(queryClient.getQueryState(SYSTEM_VERSION_QUERY_KEY)?.dataUpdatedAt ?? 0);
|
|
180
|
+
} finally {
|
|
181
|
+
refreshing.current = false;
|
|
182
|
+
setRefreshing(false);
|
|
183
|
+
}
|
|
184
|
+
}, [fetchVersion, queryClient]);
|
|
185
|
+
|
|
186
|
+
const settings = useResolvedSiteConfig();
|
|
187
|
+
const settingsRead: ModuleSettingsRead =
|
|
188
|
+
settings.status === "loading"
|
|
189
|
+
? { state: "loading" }
|
|
190
|
+
: settings.status === "error"
|
|
191
|
+
? { state: "error" }
|
|
192
|
+
: { state: "ready", features: settings.config.features, stale: settings.status === "stale" };
|
|
193
|
+
const report = q.data?.report && q.data.report.schemaVersion === 1 ? q.data.report : null;
|
|
194
|
+
const cmsVersion =
|
|
195
|
+
report?.packages.find((p) => p.role === "cms")?.installed ?? q.data?.build?.platformVersion ?? null;
|
|
196
|
+
// The latest request failed while an earlier answer is still on screen:
|
|
197
|
+
// a failed "Check again", or a failed re-read of cached data.
|
|
198
|
+
const latestCheckFailed =
|
|
199
|
+
q.data !== undefined &&
|
|
200
|
+
((refreshFailedFor !== null && refreshFailedFor === q.dataUpdatedAt) || q.isError);
|
|
201
|
+
|
|
135
202
|
return (
|
|
136
203
|
<VersionView
|
|
137
|
-
deps={{
|
|
204
|
+
deps={{
|
|
205
|
+
build: q.data?.build,
|
|
206
|
+
releases: q.data?.releases ?? [],
|
|
207
|
+
loading: q.isLoading,
|
|
208
|
+
error: q.isError && !q.data,
|
|
209
|
+
latestCheckFailed,
|
|
210
|
+
onRetry: () => void q.refetch(),
|
|
211
|
+
report,
|
|
212
|
+
modules: moduleInventory(settingsRead, report?.packages, q.isLoading ? null : cmsVersion),
|
|
213
|
+
onRefresh: report ? () => void refresh() : undefined,
|
|
214
|
+
refreshing: isRefreshing,
|
|
215
|
+
siteName: host.siteConfig.name,
|
|
216
|
+
settingsHref: paths.settings,
|
|
217
|
+
renderLink: ({ href, className, children }) => (
|
|
218
|
+
<Link to={href} className={className}>
|
|
219
|
+
{children}
|
|
220
|
+
</Link>
|
|
221
|
+
),
|
|
222
|
+
}}
|
|
138
223
|
/>
|
|
139
224
|
);
|
|
140
225
|
}
|
|
@@ -466,8 +551,8 @@ export function AdminOverviewRoute() {
|
|
|
466
551
|
},
|
|
467
552
|
{
|
|
468
553
|
to: paths.version,
|
|
469
|
-
title: "
|
|
470
|
-
body: "
|
|
554
|
+
title: "Version & modules",
|
|
555
|
+
body: "The live site release, installed CMS and module packages, module status and available updates.",
|
|
471
556
|
icon: Package,
|
|
472
557
|
},
|
|
473
558
|
...(isSuper && host.siteConfig.features.backup
|
|
@@ -21,11 +21,27 @@ import { backofficeNav, type BackofficeNavItem, type BackofficeNavOptions } from
|
|
|
21
21
|
/** Query key shared with the site-settings screen. */
|
|
22
22
|
export const SITE_SETTINGS_QUERY_KEY = ["admin", "site-settings"] as const;
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Where the resolved configuration came from:
|
|
26
|
+
*
|
|
27
|
+
* - `loading`: no answer yet; `config` is the compiled code config.
|
|
28
|
+
* - `error`: the read failed (or answered without settings) and no earlier
|
|
29
|
+
* answer is held; `config` is the compiled code config, not the live one.
|
|
30
|
+
* - `ready`: `config` includes the saved overrides.
|
|
31
|
+
* - `stale`: a re-read failed; `config` is from the last successful read.
|
|
32
|
+
*/
|
|
33
|
+
export type SiteSettingsStatus = "loading" | "error" | "ready" | "stale";
|
|
34
|
+
|
|
24
35
|
/**
|
|
25
36
|
* Code config merged with the stored overrides. While loading, the code config
|
|
26
|
-
* is returned so the shell renders without a flash of empty navigation
|
|
37
|
+
* is returned so the shell renders without a flash of empty navigation; a
|
|
38
|
+
* caller that must not present that fallback as the live answer reads `status`.
|
|
27
39
|
*/
|
|
28
|
-
export function useResolvedSiteConfig(): {
|
|
40
|
+
export function useResolvedSiteConfig(): {
|
|
41
|
+
config: SiteConfig;
|
|
42
|
+
isLoading: boolean;
|
|
43
|
+
status: SiteSettingsStatus;
|
|
44
|
+
} {
|
|
29
45
|
const host = backofficeHost();
|
|
30
46
|
const load = useServerFn(host.fns.getSiteSettings);
|
|
31
47
|
const q = useQuery({
|
|
@@ -34,13 +50,22 @@ export function useResolvedSiteConfig(): { config: SiteConfig; isLoading: boolea
|
|
|
34
50
|
staleTime: 30_000,
|
|
35
51
|
});
|
|
36
52
|
|
|
37
|
-
const
|
|
53
|
+
const fromSettings =
|
|
38
54
|
(q.data?.config as SiteConfig | undefined) ??
|
|
39
55
|
(q.data?.overrides
|
|
40
56
|
? resolveSiteConfig(host.siteConfig, q.data.overrides as never)
|
|
41
|
-
:
|
|
57
|
+
: undefined);
|
|
58
|
+
const resolved = fromSettings ?? host.siteConfig;
|
|
59
|
+
|
|
60
|
+
const status: SiteSettingsStatus = q.isLoading
|
|
61
|
+
? "loading"
|
|
62
|
+
: !fromSettings
|
|
63
|
+
? "error"
|
|
64
|
+
: q.isError
|
|
65
|
+
? "stale"
|
|
66
|
+
: "ready";
|
|
42
67
|
|
|
43
|
-
return { config: resolved, isLoading: q.isLoading };
|
|
68
|
+
return { config: resolved, isLoading: q.isLoading, status };
|
|
44
69
|
}
|
|
45
70
|
|
|
46
71
|
/** Sidebar model built from the live (override-aware) configuration. */
|
package/src/config/modules.ts
CHANGED
|
@@ -23,6 +23,8 @@ import {
|
|
|
23
23
|
MessageSquareQuote,
|
|
24
24
|
} from "lucide-react";
|
|
25
25
|
|
|
26
|
+
import { POPUP_PACKAGE } from "../popups/package-name";
|
|
27
|
+
|
|
26
28
|
/**
|
|
27
29
|
* Every module a site can switch on.
|
|
28
30
|
*
|
|
@@ -90,6 +92,14 @@ export type CmsModuleDefinition = {
|
|
|
90
92
|
* enabled, so a disabled module mounts nothing.
|
|
91
93
|
*/
|
|
92
94
|
readonly hasPublicRuntime?: boolean;
|
|
95
|
+
/**
|
|
96
|
+
* A separately published first-party package the module's runtime is built
|
|
97
|
+
* on, beyond the CMS package itself. The version screen reports its installed
|
|
98
|
+
* and published versions; every other module ships inside the CMS package and
|
|
99
|
+
* updates with it. Naming a package here also allows the server to look it up
|
|
100
|
+
* in the public registry, so only first-party packages belong here.
|
|
101
|
+
*/
|
|
102
|
+
readonly package?: `@bygghammar/${string}`;
|
|
93
103
|
};
|
|
94
104
|
|
|
95
105
|
/**
|
|
@@ -145,6 +155,7 @@ export const CMS_MODULES = [
|
|
|
145
155
|
nav: { path: "/popups", label: "Popups", icon: MessageSquare },
|
|
146
156
|
requiresRole: "staff",
|
|
147
157
|
hasPublicRuntime: true,
|
|
158
|
+
package: POPUP_PACKAGE,
|
|
148
159
|
},
|
|
149
160
|
] as const satisfies readonly CmsModuleDefinition[];
|
|
150
161
|
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The separately published package the Popups module is built on.
|
|
3
|
+
*
|
|
4
|
+
* Named here, inside the module's own directory, so the module table can
|
|
5
|
+
* declare it without the rest of the CMS mentioning the popup package. This
|
|
6
|
+
* file holds only the name: importing it loads nothing from the package.
|
|
7
|
+
*/
|
|
8
|
+
export const POPUP_PACKAGE = "@bygghammar/lightweight-popup" as const;
|