canopycms 0.8.1 → 0.8.2
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 +102 -47
- package/dist/api/admin-branch-health.d.ts +11 -0
- package/dist/api/admin-branch-health.js +36 -0
- package/dist/api/admin.d.ts +8 -1
- package/dist/api/branch-merge.js +1 -1
- package/dist/api/branch-review.js +20 -3
- package/dist/api/branch-status.js +30 -19
- package/dist/api/branch.d.ts +8 -3
- package/dist/api/branch.js +32 -3
- package/dist/api/client.d.ts +3 -1
- package/dist/api/client.js +4 -0
- package/dist/api/github-sync.d.ts +2 -0
- package/dist/api/github-sync.js +6 -1
- package/dist/api/pr-comment-sync.js +3 -1
- package/dist/branch-metadata-file.d.ts +66 -0
- package/dist/branch-metadata-file.js +14 -0
- package/dist/branch-metadata.d.ts +4 -2
- package/dist/branch-metadata.js +10 -2
- package/dist/cli/cli.js +28 -8
- package/dist/cli/generate-ai-content.js +13 -3
- package/dist/cli/init.js +2 -2
- package/dist/editor/BranchManager.d.ts +8 -4
- package/dist/editor/BranchManager.js +43 -18
- package/dist/editor/Editor.js +3 -2
- package/dist/editor/admin/SystemHealthPanel.js +65 -31
- package/dist/editor/admin/UnpublishedAtMergeAlert.d.ts +16 -0
- package/dist/editor/admin/UnpublishedAtMergeAlert.js +50 -0
- package/dist/editor/admin/useSystemHealth.d.ts +2 -0
- package/dist/editor/admin/useSystemHealth.js +21 -0
- package/dist/editor/branch-lock.d.ts +2 -0
- package/dist/editor/branch-lock.js +5 -3
- package/dist/editor/branch-status.d.ts +25 -1
- package/dist/editor/branch-status.js +75 -0
- package/dist/editor/components/EditorHeader.d.ts +6 -2
- package/dist/editor/components/EditorHeader.js +51 -30
- package/dist/editor/components/EntryHeader.d.ts +3 -1
- package/dist/editor/components/EntryHeader.js +2 -1
- package/dist/editor/components/TooltipDetail.d.ts +14 -0
- package/dist/editor/components/TooltipDetail.js +18 -0
- package/dist/editor/copy.d.ts +7 -0
- package/dist/editor/copy.js +9 -0
- package/dist/editor/hooks/useBranchManager.d.ts +8 -3
- package/dist/editor/hooks/useBranchManager.js +48 -5
- package/dist/pr-comments.d.ts +2 -0
- package/dist/pr-comments.js +20 -14
- package/dist/resource-generation.js +2 -3
- package/dist/submission-attribution.d.ts +1 -1
- package/dist/submission-attribution.js +20 -2
- package/dist/task-queue/task-queue.d.ts +5 -1
- package/dist/task-queue/task-queue.js +4 -2
- package/dist/types.d.ts +61 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/worker/branch-merge.d.ts +68 -0
- package/dist/worker/branch-merge.js +228 -0
- package/dist/worker/canopy-state.d.ts +2 -0
- package/dist/worker/canopy-state.js +1 -1
- package/dist/worker/cms-worker.d.ts +1 -0
- package/dist/worker/cms-worker.js +5 -3
- package/dist/worker/git-sync.js +15 -8
- package/dist/worker/github-commits.d.ts +106 -0
- package/dist/worker/github-commits.js +485 -0
- package/dist/worker/github-gateway-http.js +3 -2
- package/dist/worker/github-gateway-wire.d.ts +22 -13
- package/dist/worker/github-gateway-wire.js +4 -1
- package/dist/worker/github-gateway.d.ts +16 -5
- package/dist/worker/github-gateway.js +22 -5
- package/dist/worker/github-mirror.d.ts +8 -0
- package/dist/worker/github-mirror.js +12 -1
- package/dist/worker/github-transfer.d.ts +3 -1
- package/dist/worker/github-transfer.js +3 -2
- package/dist/worker/history-rewrite.d.ts +13 -1
- package/dist/worker/history-rewrite.js +27 -3
- package/dist/worker/rebase.d.ts +13 -7
- package/dist/worker/rebase.js +351 -95
- package/dist/worker/task-runner.d.ts +1 -1
- package/dist/worker/task-runner.js +172 -34
- package/dist/worker/worker-context.d.ts +2 -0
- package/package.json +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
|
-
import { ActionIcon, Badge, Button, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
|
|
4
|
+
import { ActionIcon, Anchor, Badge, Button, Group, Menu, Paper, ScrollArea, Stack, Text, TextInput, Textarea, Collapse, Tooltip, } from '@mantine/core';
|
|
5
5
|
import { IconDots, IconExternalLink } from '@tabler/icons-react';
|
|
6
6
|
import { BranchComments } from './comments/BranchComments.js';
|
|
7
7
|
import { UserBadge } from './components/UserBadge.js';
|
|
@@ -13,9 +13,10 @@ import { parseCreatedBranchName } from '../paths/validation.js';
|
|
|
13
13
|
import { DestructiveMenuItem } from './components/DestructiveMenuItem.js';
|
|
14
14
|
import { ReasonedMenuItem } from './components/ReasonedMenuItem.js';
|
|
15
15
|
import { RelativeTime } from './components/RelativeTime.js';
|
|
16
|
-
import { branchStatusPresentation } from './branch-status.js';
|
|
16
|
+
import { HOLD_BADGE, SYNC_FAILED_IN_REVIEW_NOTE, branchHoldMessage, endSentence, syncFailedFallback, branchStatusPresentation, } from './branch-status.js';
|
|
17
|
+
import { TooltipDetail } from './components/TooltipDetail.js';
|
|
17
18
|
import { branchAddress, displayBranchName } from './branch-names.js';
|
|
18
|
-
import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS } from './copy.js';
|
|
19
|
+
import { CLOSED_PULL_REQUEST_REASON, EDITOR_ACTIONS, rewrittenSubmitReason, UNFINISHED_SUBMIT_APPROVE_REASON, } from './copy.js';
|
|
19
20
|
import { isReviewerOfOthersReview } from '../authorization/review-role.js';
|
|
20
21
|
import { SYSTEM_USER_ID } from '../system-users.js';
|
|
21
22
|
/**
|
|
@@ -51,8 +52,8 @@ export const getBranchPermissions = (branch, user) => {
|
|
|
51
52
|
(userIsCreator || userInACL || isSystemBranch || userIsAdmin || userIsReviewer);
|
|
52
53
|
// Submit: can perform workflow actions AND the server says submit isn't
|
|
53
54
|
// blocked. `submitBlocked` is the server's own compound answer -- status
|
|
54
|
-
// past 'editing'
|
|
55
|
-
// `
|
|
55
|
+
// past 'editing', the protected base branch, or a rewrite on GitHub --
|
|
56
|
+
// computed once in `toBranchListItem` (api/branch.ts) and threaded through
|
|
56
57
|
// unchanged.
|
|
57
58
|
//
|
|
58
59
|
// `?? true`, not a bare `!branch.submitBlocked`: the field is optional (like
|
|
@@ -80,10 +81,11 @@ export const getBranchPermissions = (branch, user) => {
|
|
|
80
81
|
// Approve and Request changes: a Reviewer or Admin other than the creator, who for an author
|
|
81
82
|
// would only do what Withdraw does. Neither applies to the protected base branch, whose way
|
|
82
83
|
// out of review is Withdraw. A closed PR refuses both (Request changes converts the PR to
|
|
83
|
-
// a draft); Approve also needs the branch submitted, Request changes
|
|
84
|
+
// a draft); Approve also needs the branch submitted and its last submit finished, Request changes
|
|
85
|
+
// accepts approved too.
|
|
84
86
|
const reviewsOthers = (userIsAdmin || userIsReviewer) && !userIsCreator && !branch.isProtected;
|
|
85
87
|
const prLive = branch.pullRequestState !== 'closed';
|
|
86
|
-
const canApprove = reviewsOthers && branch.status === 'submitted' && prLive;
|
|
88
|
+
const canApprove = reviewsOthers && branch.status === 'submitted' && prLive && !branch.approveBlocked;
|
|
87
89
|
const canRequestChanges = reviewsOthers && (branch.status === 'submitted' || branch.status === 'approved') && prLive;
|
|
88
90
|
return {
|
|
89
91
|
hasWorkflowAccess: canPerformWorkflowActions,
|
|
@@ -102,7 +104,7 @@ const noUserLookup = async () => null;
|
|
|
102
104
|
* author's way back. A user without workflow access is offered neither, since it
|
|
103
105
|
* would never apply to them; a reason is given only for a block that depends on the branch's state.
|
|
104
106
|
*/
|
|
105
|
-
const workflowAction = (b, perms, onSubmit, onWithdraw) => {
|
|
107
|
+
const workflowAction = (b, perms, onSubmit, onWithdraw, userIsAdmin) => {
|
|
106
108
|
if (!perms.hasWorkflowAccess)
|
|
107
109
|
return undefined;
|
|
108
110
|
if (b.status === 'submitted' || b.status === 'approved') {
|
|
@@ -119,7 +121,11 @@ const workflowAction = (b, perms, onSubmit, onWithdraw) => {
|
|
|
119
121
|
color: 'brand',
|
|
120
122
|
testId: `submit-branch-button-${b.name}`,
|
|
121
123
|
run: () => onSubmit?.(b.name),
|
|
122
|
-
blockedReason: perms.canSubmit
|
|
124
|
+
blockedReason: perms.canSubmit
|
|
125
|
+
? undefined
|
|
126
|
+
: b.hold?.kind === 'rewritten'
|
|
127
|
+
? rewrittenSubmitReason(userIsAdmin)
|
|
128
|
+
: "This branch can't be submitted right now.",
|
|
123
129
|
};
|
|
124
130
|
}
|
|
125
131
|
return undefined;
|
|
@@ -133,6 +139,9 @@ const reviewsOthersBranch = (b, user) => isReviewerOfOthersReview({
|
|
|
133
139
|
isProtected: b.isProtected,
|
|
134
140
|
pullRequestState: b.pullRequestState,
|
|
135
141
|
}, user);
|
|
142
|
+
/** Lets the card's details shrink beside its actions, which a long unbroken name would push out. */
|
|
143
|
+
const CARD_DETAILS = { minWidth: 0, flex: 1 };
|
|
144
|
+
const WRAP_ANYWHERE = { overflowWrap: 'anywhere' };
|
|
136
145
|
/**
|
|
137
146
|
* One branch: its state, and one primary action, with the rest in a "…" menu. The current branch
|
|
138
147
|
* shows its workflow action; to a reviewer of someone else's submission it shows Approve, with
|
|
@@ -143,9 +152,9 @@ const reviewsOthersBranch = (b, user) => isReviewerOfOthersReview({
|
|
|
143
152
|
const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelect, onSubmit, onWithdraw, onApprove, onRequestChanges, onDelete, onGetUserMetadata, onGetOwnerMetadata = onGetUserMetadata, }) => {
|
|
144
153
|
const statusBadge = branchStatusPresentation(b.status, b.isProtected);
|
|
145
154
|
const perms = getBranchPermissions(b, user);
|
|
146
|
-
const action = workflowAction(b, perms, onSubmit, onWithdraw);
|
|
147
|
-
const workflow = action && workflowBlockedReason ? { ...action, blockedReason: workflowBlockedReason } : action;
|
|
148
155
|
const userIsAdmin = isAdmin(user?.groups);
|
|
156
|
+
const action = workflowAction(b, perms, onSubmit, onWithdraw, userIsAdmin);
|
|
157
|
+
const workflow = action && workflowBlockedReason ? { ...action, blockedReason: workflowBlockedReason } : action;
|
|
149
158
|
// The open branch holds back its review actions as it does its workflow action; the reason is
|
|
150
159
|
// given only for the open branch.
|
|
151
160
|
const heldBack = (a) => a && workflowBlockedReason ? { ...a, blockedReason: workflowBlockedReason } : a;
|
|
@@ -157,7 +166,11 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
157
166
|
color: 'brand',
|
|
158
167
|
testId: `approve-branch-button-${b.name}`,
|
|
159
168
|
run: () => onApprove?.(b.name),
|
|
160
|
-
blockedReason: perms.canApprove
|
|
169
|
+
blockedReason: perms.canApprove
|
|
170
|
+
? undefined
|
|
171
|
+
: b.pullRequestState === 'closed'
|
|
172
|
+
? CLOSED_PULL_REQUEST_REASON
|
|
173
|
+
: UNFINISHED_SUBMIT_APPROVE_REASON,
|
|
161
174
|
}
|
|
162
175
|
: undefined);
|
|
163
176
|
const requestChanges = heldBack(reviewsOthers && (b.status === 'submitted' || b.status === 'approved')
|
|
@@ -178,24 +191,36 @@ const BranchCard = ({ branch: b, isCurrent, workflowBlockedReason, user, onSelec
|
|
|
178
191
|
// alone once approved. Reviewing another one, Review opens it and the actions wait in the menu.
|
|
179
192
|
const primary = !isCurrent ? undefined : reviewing ? (approve ?? requestChanges) : workflow;
|
|
180
193
|
const secondary = isCurrent && reviewing && approve ? requestChanges : undefined;
|
|
181
|
-
// Two visible actions,
|
|
182
|
-
// width, so they take a row below
|
|
183
|
-
|
|
194
|
+
// Two visible actions, the long "Withdraw from review", or any action beside a hold's line would
|
|
195
|
+
// crowd the details at the drawer's width, so they take a row below. Open and Review stay beside
|
|
196
|
+
// the title.
|
|
197
|
+
const actionRow = !!secondary || primary?.label === EDITOR_ACTIONS.withdrawFromReview || (!!primary && !!b.hold);
|
|
184
198
|
const menuActions = [approve, requestChanges, workflow].filter((a) => !!a && a !== primary && a !== secondary);
|
|
185
199
|
const showViewPullRequest = !!b.pullRequestUrl;
|
|
186
200
|
const showDelete = !b.isProtected && (userIsAdmin || (!!user && b.createdBy === user.userId));
|
|
187
201
|
const hasMenu = menuActions.length > 0 || showViewPullRequest || showDelete;
|
|
188
202
|
const menuLabel = `More actions for ${displayBranchName(b)}`;
|
|
189
203
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
190
|
-
|
|
204
|
+
const inReview = b.status === 'submitted' || b.status === 'approved';
|
|
205
|
+
const holdViewer = {
|
|
206
|
+
role: reviewing ? 'review' : perms.canWithdraw ? 'withdraw' : 'none',
|
|
207
|
+
isAdmin: userIsAdmin,
|
|
208
|
+
};
|
|
209
|
+
return (_jsxs(Paper, { withBorder: true, p: "md", shadow: "xs", "aria-current": isCurrent ? 'true' : undefined, "data-current": isCurrent || undefined, "data-testid": `branch-list-item-${b.name}`, children: [_jsxs(Group, { justify: "space-between", align: "flex-start", wrap: "nowrap", children: [_jsxs(Stack, { gap: 4, style: CARD_DETAILS, children: [_jsxs(Group, { gap: "xs", children: [_jsx(Text, { fw: 600, style: WRAP_ANYWHERE, children: displayBranchName(b) }), isCurrent && (_jsx(Badge, { color: "brand", variant: "filled", "data-testid": `branch-current-badge-${b.name}`, children: "Current" })), statusBadge && (_jsx(Badge, { color: statusBadge.color, variant: statusBadge.variant, "data-testid": `branch-status-badge-${b.name}`, "data-status": b.status, "data-protected": b.isProtected || undefined, children: statusBadge.label })), b.hold && (_jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": `held-badge-${b.name}`, children: HOLD_BADGE.label })), b.status === 'archived' && b.mergedAt && (_jsx(Badge, { color: "teal", variant: "light", "data-testid": `branch-merged-badge-${b.name}`, children: "Merged" })), b.pullRequestNumber && (_jsxs(Badge, { color: b.pullRequestState === 'closed'
|
|
191
210
|
? 'red'
|
|
192
211
|
: b.pullRequestState === 'merged'
|
|
193
212
|
? 'violet'
|
|
194
|
-
: 'blue', variant: "light", children: ["PR #", b.pullRequestNumber, b.pullRequestState === 'closed' ? ' (closed)' : ''] })), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(
|
|
213
|
+
: 'blue', variant: "light", children: ["PR #", b.pullRequestNumber, b.pullRequestState === 'closed' ? ' (closed)' : ''] })), b.commentCount !== undefined && b.commentCount > 0 && (_jsxs(Badge, { color: "grape", variant: "light", children: [b.commentCount, " ", b.commentCount === 1 ? 'comment' : 'comments'] })), b.syncStatus === 'sync-failed' && (_jsx(Badge, { color: "red", variant: "light", "data-testid": `sync-failed-badge-${b.name}`, children: "Sync failed" })), b.syncStatus === 'pending-sync' && (_jsx(Tooltip, { label: "Changes are on their way to GitHub.", children: _jsx(Badge, { color: "gray", variant: "light", "data-testid": `pending-sync-badge-${b.name}`, children: "Syncing\u2026" }) })), b.conflictStatus === 'conflicts-detected' && (_jsx(TooltipDetail, { detail: "This branch's version was kept for conflicting entries. Review the flagged entries.", children: _jsxs(Badge, { color: "orange", variant: "light", "data-testid": `conflicts-badge-${b.name}`, children: ["Conflicts", b.conflictFiles?.length ? ` (${b.conflictFiles.length})` : ''] }) }))] }), b.hold && (_jsxs(Text, { size: "xs", maw: 480, style: WRAP_ANYWHERE, "data-testid": `branch-hold-${b.name}`, children: [branchHoldMessage(b.hold, holdViewer), b.hold.kind === 'retargeted' && b.pullRequestUrl && (_jsxs(_Fragment, { children: [' ', _jsx(Anchor, { href: b.pullRequestUrl, target: "_blank", rel: "noreferrer", inherit: true, children: "View pull request" })] }))] })), b.syncStatus === 'sync-failed' &&
|
|
214
|
+
(b.hold ? b.hold.kind !== 'rewritten' && !!b.syncFailureReason?.trim() : true) && (_jsxs(Text, { size: "xs", c: "var(--canopy-danger-text)", maw: 480, style: WRAP_ANYWHERE, "data-testid": `sync-failed-reason-${b.name}`, children: [endSentence(b.syncFailureReason?.trim() || syncFailedFallback(userIsAdmin, b.status)), !b.hold && inReview && b.pullRequestNumber && ` ${SYNC_FAILED_IN_REVIEW_NOTE}`, !b.hold &&
|
|
215
|
+
inReview &&
|
|
216
|
+
holdViewer.role === 'withdraw' &&
|
|
217
|
+
' Withdraw the branch from review to make changes.'] })), _jsxs(Group, { gap: "xs", align: "center", children: [b.updatedAt && (_jsx(RelativeTime, { iso: b.updatedAt, prefix: "Updated ", size: "xs", c: "dimmed" })), b.createdBy && (_jsxs(Group, { gap: "xs", align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "\u2022" }), _jsxs(Group, { gap: 4, align: "center", wrap: "nowrap", children: [_jsx(Text, { size: "xs", c: "dimmed", children: "Owner:" }), _jsx(UserBadge, { userId: b.createdBy, getUserMetadata: onGetOwnerMetadata ?? noUserLookup, variant: "avatar-name", size: "xs", showEmailTooltip: true, showBadge: true, badgeVariant: "light", color: "gray" })] })] }))] }), b.access && (_jsx(BranchAccessList, { users: b.access.users, groups: b.access.groups, withheldUsers: b.access.withheldUsers, withheldGroups: b.access.withheldGroups, getUserMetadata: onGetOwnerMetadata ?? noUserLookup }))] }), _jsxs(Group, { gap: 8, wrap: "nowrap", children: [!isCurrent && (_jsx(Button, { variant: "light", onClick: () => onSelect?.(b.name), "data-testid": reviewing ? `review-branch-button-${b.name}` : `switch-to-branch-button-${b.name}`, children: reviewing ? EDITOR_ACTIONS.review : 'Open' })), !actionRow && primary && _jsx(CardActionButton, { action: primary }), hasMenu && (_jsxs(Menu, { withinPortal: true, shadow: "sm", position: "bottom-end", width: 280, onChange: setMenuOpen, children: [_jsx(Tooltip, { label: menuLabel, disabled: menuOpen, children: _jsx(Menu.Target, { children: _jsx(ActionIcon, { variant: "subtle", color: "gray", "aria-label": menuLabel, "data-testid": `branch-actions-menu-${b.name}`, children: _jsx(IconDots, { size: 16, "aria-hidden": true }) }) }) }), _jsxs(Menu.Dropdown, { children: [menuActions.map((action) => (_jsx(ReasonedMenuItem, { blockedReason: action.blockedReason, showReason: !action.reasonShownBy, reasonId: `${action.testId}-reason`, reasonDescribedBy: action.reasonShownBy ? `${action.reasonShownBy}-reason` : undefined, onClick: action.run, "data-testid": action.testId, children: action.label }, action.testId))), showViewPullRequest && (_jsx(Menu.Item, { component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", leftSection: _jsx(IconExternalLink, { size: 14, "aria-hidden": true }), "data-testid": `view-pull-request-${b.name}`, children: b.pullRequestNumber
|
|
195
218
|
? `View pull request #${b.pullRequestNumber}`
|
|
196
219
|
: 'View pull request' })), showDelete && (_jsxs(_Fragment, { children: [(menuActions.length > 0 || showViewPullRequest) && _jsx(Menu.Divider, {}), _jsx(DestructiveMenuItem, { "data-testid": `delete-branch-button-${b.name}`, blockedReason: b.status === 'submitted' || b.status === 'approved'
|
|
197
220
|
? 'This branch is in review. Withdraw it from review before deleting it.'
|
|
198
|
-
:
|
|
221
|
+
: b.hold?.kind === 'unpublished-at-merge' && !userIsAdmin
|
|
222
|
+
? 'This branch may hold changes that never reached GitHub. Ask an admin to check it before deleting it.'
|
|
223
|
+
: undefined, onClick: () => onDelete?.(b.name), children: "Delete branch" })] }))] })] }))] })] }), actionRow && (_jsxs(Group, { justify: "flex-end", gap: 8, wrap: "nowrap", mt: "sm", "data-testid": `branch-action-row-${b.name}`, children: [secondary && _jsx(CardActionButton, { action: secondary }), primary && _jsx(CardActionButton, { action: primary })] }))] }));
|
|
199
224
|
};
|
|
200
225
|
const CardActionButton = ({ action }) => (_jsx(Tooltip, { label: action.blockedReason, disabled: !action.blockedReason, multiline: true, maw: 280, children: _jsx(Button, { variant: "light", color: action.color, onClick: () => {
|
|
201
226
|
if (!action.blockedReason)
|
package/dist/editor/Editor.js
CHANGED
|
@@ -1578,6 +1578,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1578
1578
|
branchMissing,
|
|
1579
1579
|
reviewing: branchReviewing,
|
|
1580
1580
|
pullRequestClosed: branchPullRequestClosed,
|
|
1581
|
+
approveBlocked: currentBranch?.approveBlocked,
|
|
1581
1582
|
}), dirtyPaths: dirtyPaths, reveal: contentReveal, onRevealed: () => setContentReveal(undefined), entryFilter: entryFilter === 'changed' && !changesOffered ? 'all' : entryFilter, onEntryFilterChange: setEntryFilter, onShowUnsaved: showUnsaved, onShowDeletedChanges: (entry, label) => showEntryChanges(entry, label, 'deleted row'), branchChanges: changesOffered
|
|
1582
1583
|
? {
|
|
1583
1584
|
base: branchChanges.data?.base ?? currentBranch?.baseBranch ?? 'the base branch',
|
|
@@ -1601,7 +1602,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1601
1602
|
// branchWriteBlocked reuses branchContentLocked itself rather than
|
|
1602
1603
|
// re-deriving `?? true` a second time here, so there is exactly one
|
|
1603
1604
|
// fail-closed computation of "are writes blocked" in this component.
|
|
1604
|
-
branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, onBranchReloadData: () => {
|
|
1605
|
+
branchIsProtected: currentBranch?.isProtected ?? true, branchServerProtected: currentBranch?.isProtected, branchReadOnly: currentBranch?.readOnly, branchHold: currentBranch?.hold, branchApproveBlocked: currentBranch?.approveBlocked, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchToggleRef: branchToggleRef, onBranchReloadData: () => {
|
|
1605
1606
|
void Promise.all([handleReloadBranchData(), refreshEntries(branchNameState)]).catch(console.error);
|
|
1606
1607
|
}, onBranchDiscardDrafts: () => handleDiscardDrafts(() => branchToggleRef.current), onBranchManagerOpen: openBranchManager, onNewBranch: () => openBranchManager({ create: true }), onSaveAll: runSaveAll, saveAllProgress: saveAllProgress,
|
|
1607
1608
|
// On any entry: EntryHeader's `saving` is the open entry's alone.
|
|
@@ -1634,7 +1635,7 @@ const EditorContent = ({ entries, title, siteTitle = title, branchName = '', col
|
|
|
1634
1635
|
// True exactly while the form below shows "Setting up…" or "Loading content…".
|
|
1635
1636
|
entryLoading: !currentEntry && ((!branchNameState && busy) || entriesInitializing), branchName: branchNameState, busy: busy, breadcrumbSegments: breadcrumbSegments,
|
|
1636
1637
|
// A draft kept for an unavailable entry stays in storage but cannot be saved.
|
|
1637
|
-
hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onShowChanges: openEntryChange
|
|
1638
|
+
hasUnsavedChanges: !currentEntryUnavailable && !contentReadOnly && isSelectedDirty(), entryHasDraft: isSelectedDirty(), entryReadOnly: currentEntry?.canEdit === false, entryUnavailable: currentEntryUnavailable, draftStorageFailed: draftStorageFailed, branchStatus: currentBranch?.status, branchReadOnly: currentBranch?.readOnly, branchWriteBlocked: branchContentLocked, branchLoading: branchLoading, branchPending: branchPending, branchMissing: branchMissing, branchReviewing: branchReviewing, branchPullRequestClosed: branchPullRequestClosed, branchApproveBlocked: currentBranch?.approveBlocked, onCollectionOpen: (path) => revealInContentPanel({ kind: 'collection', path }), onEntryReveal: () => currentEntry && revealInContentPanel({ kind: 'entry', path: currentEntry.path }), navigatorToggleRef: navigatorToggleRef, onShowChanges: openEntryChange
|
|
1638
1639
|
? () => showEntryChanges(openEntryChange, currentEntry?.label ?? currentEntry?.slug ?? '', 'header')
|
|
1639
1640
|
: undefined, showChangesPending: changesOffered && !!currentEntry, showChangesRef: showChangesRef, onFileReload: () => handleReload(() => navigatorToggleRef.current), onFileDiscardDraft: () => handleDiscardFileDraft(() => navigatorToggleRef.current), onEntryRename: () => currentEntry && handleRenameEntry(currentEntry.path, true), onEntryDelete: () => currentEntry && handleDeleteEntry(currentEntry.path, true), onSave: currentEntryUnavailable ? noop : handleSave, saveAllRunning: !!saveAllProgress, saving: singleSaving?.branch === branchNameState &&
|
|
1640
1641
|
singleSaving.contentId === currentEntry?.contentId, savedSignal: lastSaved?.seq, savedHere: lastSaved?.branch === branchNameState &&
|
|
@@ -9,8 +9,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
9
9
|
* only for admins (see isAdmin(userContext?.groups)); this component does not
|
|
10
10
|
* re-check that itself, same as GroupManager and PermissionManager.
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { useRef } from 'react';
|
|
13
|
+
import { Alert, Badge, Button, Code, Group, Loader, Modal, Paper, SegmentedControl, SimpleGrid, Spoiler, Stack, Table, Tabs, Text, ThemeIcon, Tooltip, } from '@mantine/core';
|
|
14
14
|
import { IconAlertCircle, IconAlertTriangle } from '@tabler/icons-react';
|
|
15
15
|
import { openConfirm } from '../utils/confirm-modal.js';
|
|
16
16
|
import { focusReturnProps } from '../utils/focus-return.js';
|
|
@@ -18,7 +18,9 @@ import { formatRelativeTime } from '../relative-time.js';
|
|
|
18
18
|
import { EDITOR_ACTIONS } from '../copy.js';
|
|
19
19
|
import { useSystemHealth, } from './useSystemHealth.js';
|
|
20
20
|
import { escapeForLog } from '../../utils/escape-text.js';
|
|
21
|
-
import {
|
|
21
|
+
import { TooltipDetail } from '../components/TooltipDetail.js';
|
|
22
|
+
import { DISMISS_UNPUBLISHED_ATTRIBUTE, UnpublishedAtMergeAlert } from './UnpublishedAtMergeAlert.js';
|
|
23
|
+
import { HOLD_BADGE, branchHoldMessage, branchStatusPresentation, endSentence, } from '../branch-status.js';
|
|
22
24
|
// ============================================================================
|
|
23
25
|
// Small pure helpers
|
|
24
26
|
// ============================================================================
|
|
@@ -104,6 +106,14 @@ const BASE_REFRESH_LABELS = {
|
|
|
104
106
|
'skipped-not-provisioned': 'not yet provisioned',
|
|
105
107
|
failed: 'refresh failed',
|
|
106
108
|
};
|
|
109
|
+
/**
|
|
110
|
+
* A failed sync's detail, with the advice to retry only when a retry can help. The worker refuses
|
|
111
|
+
* every push while a rewrite stands, an archived branch has nothing left to finish, and a submit
|
|
112
|
+
* that was never queued has no task.
|
|
113
|
+
*/
|
|
114
|
+
function syncFailedDetail(reason = 'GitHub sync failed.', retryable) {
|
|
115
|
+
return retryable ? `${endSentence(reason)}\nRetry it from the "Tasks" tab.` : reason;
|
|
116
|
+
}
|
|
107
117
|
/**
|
|
108
118
|
* The worker holding the base branch for an editor deploy, or the cycle it stopped waiting.
|
|
109
119
|
* The rule and its bound live in worker/schema-gate.ts.
|
|
@@ -121,7 +131,7 @@ function BaseHoldAlert({ hold }) {
|
|
|
121
131
|
* and never listed. Names come from GitHub and are attacker-chosen, so each is shown escaped.
|
|
122
132
|
*/
|
|
123
133
|
function DivergedBranchesAlert({ names }) {
|
|
124
|
-
return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${plural(names.length, 'branch')} diverged from GitHub`, "data-testid": "diverged-branches-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "This deployment's copy of each branch below holds editor work that conflicts with the branch on GitHub, usually because the GitHub branch was rebased or force-pushed, or because another CanopyCMS deployment sharing this repository uses the same branch name. Neither copy has been changed, and publishing the branch fails until they agree." }), _jsx(Text, { size: "sm", children: "A developer decides which version to keep. To keep GitHub's, first push it to a new branch name on GitHub, because \"Delete branch\" in the editor also deletes a published branch on GitHub; then delete this branch in the editor (withdraw it from review first if it is in review). The editor's changes cannot be copied to another branch: to keep them, leave this branch as it is, or re-enter them by hand in a new branch. For the base branch or a settings branch, ask a developer to check what changed on GitHub." }), _jsx(Group, { gap: 4, children: names.map((name) => (_jsx(Code, { children: escapeForLog(name) }, name))) })] }) }));
|
|
134
|
+
return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${plural(names.length, 'branch')} diverged from GitHub`, "data-testid": "diverged-branches-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "This deployment's copy of each branch below holds editor work that conflicts with the branch on GitHub, usually because the GitHub branch was rebased or force-pushed, or because another CanopyCMS deployment sharing this repository uses the same branch name. Neither copy has been changed, and publishing the branch fails until they agree. A branch that only gained commits on GitHub clears on its own at the next sync." }), _jsx(Text, { size: "sm", children: "A developer decides which version to keep. To keep GitHub's, first push it to a new branch name on GitHub, because \"Delete branch\" in the editor also deletes a published branch on GitHub; then delete this branch in the editor (withdraw it from review first if it is in review). The editor's changes cannot be copied to another branch: to keep them, leave this branch as it is, or re-enter them by hand in a new branch. For the base branch or a settings branch, ask a developer to check what changed on GitHub." }), _jsx(Group, { gap: 4, children: names.map((name) => (_jsx(Code, { children: escapeForLog(name) }, name))) })] }) }));
|
|
125
135
|
}
|
|
126
136
|
/**
|
|
127
137
|
* The branch namespace the CMS creates branches in, against the one the GitHub gateway writes. A
|
|
@@ -249,10 +259,32 @@ function duplicateScanSummary(health, healthyDirNames) {
|
|
|
249
259
|
const PURGE_CONFIRM_TEXT = 'The directory is moved to a hidden trash name and kept for 30 days, then deleted. Any git work inside was never pushed and will be lost when the trash is swept.';
|
|
250
260
|
export function SystemHealthPanel({ opened, onClose, returnFocusTo }) {
|
|
251
261
|
const health = useSystemHealth({ isOpen: opened });
|
|
252
|
-
|
|
262
|
+
const overviewTabRef = useRef(null);
|
|
263
|
+
return (_jsx(Modal, { opened: opened, onClose: onClose, title: "System health", size: "xl", ...focusReturnProps(returnFocusTo), children: _jsxs(Tabs, { defaultValue: "overview", children: [_jsxs(Tabs.List, { children: [_jsx(Tabs.Tab, { ref: overviewTabRef, value: "overview", children: "Overview" }), _jsx(Tabs.Tab, { value: "tasks", children: "Tasks" }), _jsx(Tabs.Tab, { value: "branches", children: "Branches" })] }), _jsx(Tabs.Panel, { value: "overview", pt: "md", children: _jsx(OverviewTab, { health: health, overviewTabRef: overviewTabRef }) }), _jsx(Tabs.Panel, { value: "tasks", pt: "md", children: _jsx(TasksTab, { health: health }) }), _jsx(Tabs.Panel, { value: "branches", pt: "md", children: _jsx(BranchesTab, { health: health }) })] }) }));
|
|
253
264
|
}
|
|
254
|
-
function OverviewTab({ health }) {
|
|
265
|
+
function OverviewTab({ health, overviewTabRef, }) {
|
|
255
266
|
const { status, statusLoading, isRecentFatalError, error, refresh } = health;
|
|
267
|
+
const unpublished = (health.branchHealth?.entries ?? []).flatMap((entry) => entry.kind === 'healthy' && entry.branch?.unpublishedAtMerge
|
|
268
|
+
? [
|
|
269
|
+
{
|
|
270
|
+
dirName: entry.dirName,
|
|
271
|
+
name: entry.branch.name,
|
|
272
|
+
pullRequestUrl: entry.branch.pullRequestUrl,
|
|
273
|
+
},
|
|
274
|
+
]
|
|
275
|
+
: []);
|
|
276
|
+
// The row goes with the dismissal, so focus moves to the next one, else to the tab.
|
|
277
|
+
const overviewRef = useRef(null);
|
|
278
|
+
const handleDismissUnpublished = async (dirName) => {
|
|
279
|
+
await health.dismissUnpublished(dirName);
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
const overview = overviewRef.current;
|
|
282
|
+
if (!overview || overview.contains(document.activeElement))
|
|
283
|
+
return;
|
|
284
|
+
const next = overview.querySelector(`[${DISMISS_UNPUBLISHED_ATTRIBUTE}]`);
|
|
285
|
+
(next ?? overviewTabRef.current)?.focus();
|
|
286
|
+
});
|
|
287
|
+
};
|
|
256
288
|
if (!status && statusLoading) {
|
|
257
289
|
return (_jsxs(Group, { justify: "center", py: "xl", children: [_jsx(Loader, { size: "md" }), _jsx(Text, { size: "sm", c: "dimmed", children: "Loading status..." })] }));
|
|
258
290
|
}
|
|
@@ -271,9 +303,9 @@ function OverviewTab({ health }) {
|
|
|
271
303
|
const versionSkew = status.worker.state === 'alive' &&
|
|
272
304
|
workerVersion !== undefined &&
|
|
273
305
|
workerVersion !== build.canopycmsVersion;
|
|
274
|
-
return (_jsxs(Stack, { gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: lastFatalError.phase === 'startup'
|
|
306
|
+
return (_jsxs(Stack, { ref: overviewRef, gap: "md", children: [error && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), variant: "light", children: error })), _jsxs(Group, { gap: "sm", children: [_jsx(Badge, { color: liveness.color, variant: "light", size: "lg", children: liveness.label }), status.mode === 'dev' && (_jsx(Text, { size: "xs", c: "dimmed", children: "No worker runs in dev mode" }))] }), isRecentFatalError && lastFatalError && (_jsxs(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: lastFatalError.phase === 'startup'
|
|
275
307
|
? 'The worker failed to start'
|
|
276
|
-
: 'The worker stopped while running', children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.schemaIssues && status.schemaIssues.length > 0 && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "Content types this editor version doesn't know", "data-testid": "schema-issues-alert", children: _jsx(Stack, { gap: 4, children: status.schemaIssues.map((issue) => (_jsx(Text, { size: "sm", children: issue.kind === 'unknown-schema' ? (_jsxs(_Fragment, { children: [_jsx(Code, { children: issue.metaFile }), " names ", _jsx(Code, { children: issue.schemaRef }), ", so entry type ", _jsx(Code, { children: issue.entryType }), " is unavailable"] })) : (issue.message) }, `${issue.kind}:${issue.message}`))) }) })), _jsx(BranchNamespaceAlert, { own: status.branchNamespace, gateway: status.workerStatus?.gatewayBranchNamespace }), status.workerStatus?.baseHold && _jsx(BaseHoldAlert, { hold: status.workerStatus.baseHold }), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), lastGitSync?.tracked && lastGitSync.tracked.diverged.length > 0 && (_jsx(DivergedBranchesAlert, { names: lastGitSync.tracked.diverged })), status.workerStatus?.unpublishableBranches && (_jsx(UnpublishableBranchesAlert, { report: status.workerStatus.unpublishableBranches, namespace: status.workerStatus.gatewayBranchNamespace })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), lastShutdown && (_jsxs(Text, { size: "xs", c: lastShutdown.outcome === 'drained' ? 'dimmed' : 'orange', "data-testid": "build-last-shutdown", children: ["Last worker shutdown: ", lastShutdown.reason, " at ", lastShutdown.at, lastShutdown.outcome === 'deadline' &&
|
|
308
|
+
: 'The worker stopped while running', children: [_jsx(Text, { size: "sm", children: lastFatalError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", lastFatalError.at] })] })), status.settingsWorkspaceError && (_jsx(Alert, { color: "red", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Settings workspace unavailable: groups and path rules are not loading", children: _jsx(Text, { size: "sm", children: status.settingsWorkspaceError }) })), status.schemaIssues && status.schemaIssues.length > 0 && (_jsx(Alert, { color: "yellow", icon: _jsx(IconAlertTriangle, { size: 16 }), title: "Content types this editor version doesn't know", "data-testid": "schema-issues-alert", children: _jsx(Stack, { gap: 4, children: status.schemaIssues.map((issue) => (_jsx(Text, { size: "sm", children: issue.kind === 'unknown-schema' ? (_jsxs(_Fragment, { children: [_jsx(Code, { children: issue.metaFile }), " names ", _jsx(Code, { children: issue.schemaRef }), ", so entry type ", _jsx(Code, { children: issue.entryType }), " is unavailable"] })) : (issue.message) }, `${issue.kind}:${issue.message}`))) }) })), _jsx(BranchNamespaceAlert, { own: status.branchNamespace, gateway: status.workerStatus?.gatewayBranchNamespace }), status.workerStatus?.baseHold && _jsx(BaseHoldAlert, { hold: status.workerStatus.baseHold }), unpublished.length > 0 && (_jsx(UnpublishedAtMergeAlert, { branches: unpublished, onDismiss: handleDismissUnpublished })), status.statusReadError && (_jsxs(Text, { size: "xs", c: "orange", children: ["Warning: could not read worker status (", status.statusReadError, ")"] })), lastGitSync?.tracked && lastGitSync.tracked.diverged.length > 0 && (_jsx(DivergedBranchesAlert, { names: lastGitSync.tracked.diverged })), status.workerStatus?.unpublishableBranches && (_jsx(UnpublishableBranchesAlert, { report: status.workerStatus.unpublishableBranches, namespace: status.workerStatus.gatewayBranchNamespace })), status.workerStatus?.lastGitSyncError && (_jsxs(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "Last git sync failed", children: [_jsx(Text, { size: "sm", children: status.workerStatus.lastGitSyncError.message }), _jsxs(Text, { size: "xs", c: "dimmed", mt: 4, children: ["at ", status.workerStatus.lastGitSyncError.at] })] })), versionSkew && (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertCircle, { size: 16 }), title: "API and worker versions differ", "data-testid": "version-skew-warning", children: _jsxs(Text, { size: "sm", children: ["The API runs canopycms ", build.canopycmsVersion, " but the worker runs canopycms", ' ', workerVersion, ". They were deployed from different builds."] }) })), _jsxs(Paper, { withBorder: true, p: "sm", children: [_jsx(Text, { size: "sm", fw: 600, children: "Build" }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-api-version", children: ["API: canopycms ", build.canopycmsVersion] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-source-revision", children: ["Source revision:", ' ', build.sourceRevision ? (_jsx(Tooltip, { label: build.sourceRevision, children: _jsx(Code, { children: build.sourceRevision.slice(0, 12) }) })) : ('not set (pass the CANOPY_SOURCE_SHA build arg to the image build)')] }), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-worker-version", children: ["Worker version: ", workerVersion ? `canopycms ${workerVersion}` : 'unknown'] }), lastShutdown && (_jsxs(Text, { size: "xs", c: lastShutdown.outcome === 'drained' ? 'dimmed' : 'orange', "data-testid": "build-last-shutdown", children: ["Last worker shutdown: ", lastShutdown.reason, " at ", lastShutdown.at, lastShutdown.outcome === 'deadline' &&
|
|
277
309
|
` · drain deadline hit, aborted ${lastShutdown.abandoned?.join(', ') ?? 'in-flight work'}`, lastShutdown.outcome === 'drained' &&
|
|
278
310
|
lastShutdown.drainMs !== undefined &&
|
|
279
311
|
` · drained in ${(lastShutdown.drainMs / 1000).toFixed(1)}s`, lastShutdown.outcome === 'not-drained' && ' (a crash or a forced stop)'] })), lastFatalError?.phase === 'startup' && (_jsxs(Text, { size: "xs", c: "orange", "data-testid": "build-failed-start", children: ["Last start failed at ", lastFatalError.at] })), _jsxs(Text, { size: "xs", c: "dimmed", "data-testid": "build-media", children: ["Media storage:", ' ', status.assetStore.configured ? 'configured' : 'not configured — uploads are disabled'] }), _jsxs(Text, { size: "xs", c: status.imageProcessing.available ? 'dimmed' : 'orange', "data-testid": "build-image-processing", children: ["Image processing:", ' ', status.imageProcessing.available
|
|
@@ -384,33 +416,35 @@ const firstFailed = (iso) => {
|
|
|
384
416
|
const date = new Date(iso);
|
|
385
417
|
return Number.isNaN(date.getTime()) ? iso : `${formatRelativeTime(iso)}, ${date.toLocaleString()}`;
|
|
386
418
|
};
|
|
387
|
-
/**
|
|
388
|
-
* A detail that only a Tooltip would show: hidden text for assistive tech, plus a Tooltip that
|
|
389
|
-
* opens on hover, focus and touch. The trigger is a focusable span, not a button: it does nothing
|
|
390
|
-
* when pressed, so it must not be announced as one. A trigger with no text of its own, such as an
|
|
391
|
-
* icon, takes its name from `name`.
|
|
392
|
-
*/
|
|
393
|
-
function TooltipDetail({ detail, name, maw = 320, preLine = false, children, }) {
|
|
394
|
-
const detailId = useId();
|
|
395
|
-
return (_jsxs(_Fragment, { children: [_jsx(Tooltip
|
|
396
|
-
// A sighted user sees only the icon, so the tooltip names it too.
|
|
397
|
-
, {
|
|
398
|
-
// A sighted user sees only the icon, so the tooltip names it too.
|
|
399
|
-
label: name ? `${name}: ${detail}` : detail, multiline: true, maw: maw, style: preLine ? { whiteSpace: 'pre-line' } : undefined, events: { hover: true, focus: true, touch: true }, children: _jsx(Box, { component: "span", tabIndex: 0, role: name ? 'img' : undefined, "aria-label": name, "aria-describedby": detailId, className: "mantine-focus-auto", children: children }) }), _jsx(VisuallyHidden, { id: detailId, children: detail })] }));
|
|
400
|
-
}
|
|
401
419
|
function BranchHealthRow({ entry, baseWarning, duplicateIdScan, onMarkMerged, onRepair, onPurge, onDuplicateIds, }) {
|
|
402
420
|
if (entry.kind === 'healthy' && entry.branch) {
|
|
403
421
|
const b = entry.branch;
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const
|
|
422
|
+
// The worker looks at every branch but 'archived' (already merged): one in review is not
|
|
423
|
+
// rebased, but commits from GitHub are still brought into it. An exclusion, not a list of
|
|
424
|
+
// statuses, so a status added later still shows its failures by default.
|
|
425
|
+
const showRebaseFailure = b.status !== 'archived' && !!b.rebaseFailure;
|
|
426
|
+
const inReview = b.status === 'submitted' || b.status === 'approved';
|
|
427
|
+
const hold = b.rewrittenOnGitHub !== undefined && b.status !== 'archived'
|
|
428
|
+
? { kind: 'rewritten' }
|
|
429
|
+
: !inReview
|
|
430
|
+
? b.unpublishedAtMerge
|
|
431
|
+
? { kind: 'unpublished-at-merge' }
|
|
432
|
+
: undefined
|
|
433
|
+
: b.submitUnfinished
|
|
434
|
+
? {
|
|
435
|
+
kind: 'submit-unfinished',
|
|
436
|
+
opened: b.pullRequestNumber !== undefined,
|
|
437
|
+
notQueued: b.submitNotQueued,
|
|
438
|
+
}
|
|
439
|
+
: b.pullRequestHold;
|
|
410
440
|
const canMarkMerged = (b.status === 'submitted' || b.status === 'approved') && !!b.pullRequestNumber;
|
|
411
|
-
return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "Base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusPresentation(b.status)?.color ?? 'neutral', variant: "light", children: branchStatusPresentation(b.status)?.label }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(TooltipDetail, { detail: b.syncFailureReason
|
|
412
|
-
|
|
413
|
-
|
|
441
|
+
return (_jsxs(Table.Tr, { children: [_jsx(Table.Td, { children: _jsxs(Group, { gap: 4, wrap: "nowrap", children: [_jsx(Text, { size: "sm", children: entry.dirName }), entry.isBaseBranch && (_jsx(Badge, { size: "xs", color: "gray", variant: "outline", children: "Base" }))] }) }), _jsx(Table.Td, { children: _jsx(Badge, { color: branchStatusPresentation(b.status)?.color ?? 'neutral', variant: "light", children: branchStatusPresentation(b.status)?.label }) }), _jsx(Table.Td, { children: b.pullRequestNumber ? (_jsxs(Text, { size: "xs", c: "blue", component: "a", href: b.pullRequestUrl, target: "_blank", rel: "noopener noreferrer", style: { textDecoration: 'underline' }, children: ["PR #", b.pullRequestNumber, b.pullRequestState ? ` (${b.pullRequestState})` : ''] })) : (_jsx(Text, { size: "xs", c: "dimmed", children: "\u2014" })) }), _jsx(Table.Td, { children: b.syncStatus === 'sync-failed' ? (_jsx(TooltipDetail, { preLine: true, detail: syncFailedDetail(b.syncFailureReason, hold?.kind !== 'rewritten' &&
|
|
442
|
+
b.status !== 'archived' &&
|
|
443
|
+
!(b.submitUnfinished && b.submitNotQueued)), children: _jsx(Badge, { color: "red", variant: "light", children: "Sync failed" }) })) : b.syncStatus === 'pending-sync' ? (_jsx(Badge, { color: "gray", variant: "light", children: "Pending sync" })) : null }), _jsx(Table.Td, { children: _jsxs(Group, { gap: 6, children: [!!b.conflictFiles?.length && (_jsxs(Badge, { color: "orange", variant: "light", children: [b.conflictFiles.length, " conflict", b.conflictFiles.length === 1 ? '' : 's'] })), hold && (_jsx(TooltipDetail, { detail: branchHoldMessage(hold, {
|
|
444
|
+
role: 'none',
|
|
445
|
+
isAdmin: true,
|
|
446
|
+
inSystemHealth: true,
|
|
447
|
+
}), children: _jsx(Badge, { color: HOLD_BADGE.color, variant: "light", "data-testid": `held-pr-${entry.dirName}`, children: HOLD_BADGE.label }) })), showRebaseFailure && b.rebaseFailure && (_jsx(TooltipDetail, { name: "Rebase failing", detail: `${b.rebaseFailure.message} (first failed ${firstFailed(b.rebaseFailure.firstAt)})`, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `rebase-failure-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), baseWarning && (_jsx(TooltipDetail, { name: "Base branch refresh failing", detail: baseWarning, maw: 420, preLine: true, children: _jsx(ThemeIcon, { color: "yellow", variant: "light", size: "sm", radius: "xl", "data-testid": `base-refresh-warning-${entry.dirName}`, children: _jsx(IconAlertTriangle, { size: 12 }) }) })), duplicateIdScan?.state === 'found' && (_jsx(Tooltip, { label: "Review and fix", children: _jsx(Badge, { component: "button", type: "button", color: "orange", variant: "light", style: { cursor: 'pointer' }, "data-testid": `duplicate-content-ids-${entry.dirName}`, onClick: () => onDuplicateIds(entry.dirName, duplicateIdScan.duplicates), children: plural(duplicateIdScan.duplicates.length, 'duplicate ID') }) })), duplicateIdScan !== undefined &&
|
|
414
448
|
duplicateIdScan?.state !== 'found' &&
|
|
415
449
|
duplicateIdScan?.state !== 'none' && (_jsx(Tooltip, { label: duplicateUncheckedReason(duplicateIdScan), multiline: true, maw: 320, children: _jsx(Badge, { color: "gray", variant: "outline", "data-testid": `duplicate-ids-unchecked-${entry.dirName}`, children: "IDs not checked" }) }))] }) }), _jsx(Table.Td, { children: _jsx(Text, { size: "xs", c: "dimmed", children: b.updatedAt }) }), _jsx(Table.Td, { children: canMarkMerged && (_jsx(Button, { variant: "light", "data-testid": `mark-merged-${b.name}`, onClick: () => onMarkMerged(b.name), children: "Mark merged" })) })] }));
|
|
416
450
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Marks each row's Dismiss, for the panel to move focus to once a row has gone. */
|
|
2
|
+
export declare const DISMISS_UNPUBLISHED_ATTRIBUTE = "data-dismiss-unpublished";
|
|
3
|
+
export interface UnpublishedBranch {
|
|
4
|
+
dirName: string;
|
|
5
|
+
name: string;
|
|
6
|
+
pullRequestUrl?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Branches merged before a submit finished, listed until an admin dismisses them or deletes the
|
|
10
|
+
* branch: archiving leaves no retry or new submit to finish it. Names come from GitHub and are
|
|
11
|
+
* attacker-chosen, so each is shown escaped.
|
|
12
|
+
*/
|
|
13
|
+
export declare function UnpublishedAtMergeAlert({ branches, onDismiss, }: {
|
|
14
|
+
branches: readonly UnpublishedBranch[];
|
|
15
|
+
onDismiss: (dirName: string) => Promise<void>;
|
|
16
|
+
}): import("react/jsx-runtime").JSX.Element;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
4
|
+
import { Alert, Anchor, Button, Code, Group, Stack, Text } from '@mantine/core';
|
|
5
|
+
import { IconAlertTriangle } from '@tabler/icons-react';
|
|
6
|
+
import { EDITOR_ACTIONS } from '../copy.js';
|
|
7
|
+
import { escapeForLog } from '../../utils/escape-text.js';
|
|
8
|
+
/** Marks each row's Dismiss, for the panel to move focus to once a row has gone. */
|
|
9
|
+
export const DISMISS_UNPUBLISHED_ATTRIBUTE = 'data-dismiss-unpublished';
|
|
10
|
+
/** One branch, whose Dismiss asks in its own row: System health is already a modal. */
|
|
11
|
+
function UnpublishedRow({ branch, onDismiss, }) {
|
|
12
|
+
const { dirName, pullRequestUrl } = branch;
|
|
13
|
+
const name = escapeForLog(branch.name);
|
|
14
|
+
const [asking, setAsking] = useState(false);
|
|
15
|
+
const [dismissing, setDismissing] = useState(false);
|
|
16
|
+
const dismissRef = useRef(null);
|
|
17
|
+
const confirmRef = useRef(null);
|
|
18
|
+
const asked = useRef(false);
|
|
19
|
+
const questionId = useId();
|
|
20
|
+
// The button pressed is replaced each way, so focus follows to its counterpart.
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (asking)
|
|
23
|
+
confirmRef.current?.focus();
|
|
24
|
+
else if (asked.current)
|
|
25
|
+
dismissRef.current?.focus();
|
|
26
|
+
asked.current = asking;
|
|
27
|
+
}, [asking]);
|
|
28
|
+
const dismiss = async () => {
|
|
29
|
+
setDismissing(true);
|
|
30
|
+
try {
|
|
31
|
+
await onDismiss(dirName);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
setDismissing(false);
|
|
35
|
+
setAsking(false);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
return (_jsxs(Group, { gap: "xs", justify: "space-between", "data-testid": `unpublished-branch-${dirName}`, children: [_jsxs(Group, { gap: "xs", style: { minWidth: 0, flex: '1 1 200px' }, children: [_jsx(Code, { style: { overflowWrap: 'anywhere', minWidth: 0 }, children: name }), pullRequestUrl && (_jsx(Anchor, { href: pullRequestUrl, target: "_blank", rel: "noopener noreferrer", size: "xs", c: "var(--mantine-color-text)", underline: "always", children: "View pull request" }))] }), asking ? (_jsxs(Group, { gap: 4, style: { maxWidth: '100%' }, children: [_jsx(Text, { id: questionId, size: "xs", children: "Dismiss this warning? It won't come back, and the branch can then be deleted." }), _jsx(Button, { ref: confirmRef, size: "compact-xs", variant: "default", loading: dismissing, "aria-label": `Dismiss the warning for ${name}`, "aria-describedby": questionId, "data-testid": `confirm-dismiss-unpublished-${dirName}`, onClick: () => void dismiss(), children: "Dismiss" }), _jsx(Button, { size: "compact-xs", variant: "subtle", color: "gray", disabled: dismissing, "aria-label": `Cancel dismissing the warning for ${name}`, "data-testid": `cancel-dismiss-unpublished-${dirName}`, onClick: () => setAsking(false), children: "Cancel" })] })) : (_jsx(Button, { ref: dismissRef, size: "compact-xs", variant: "default", style: { flexShrink: 0 }, "aria-label": `Dismiss the warning for ${name}`, "data-testid": `dismiss-unpublished-${dirName}`, [DISMISS_UNPUBLISHED_ATTRIBUTE]: true, onClick: () => setAsking(true), children: "Dismiss" }))] }));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Branches merged before a submit finished, listed until an admin dismisses them or deletes the
|
|
42
|
+
* branch: archiving leaves no retry or new submit to finish it. Names come from GitHub and are
|
|
43
|
+
* attacker-chosen, so each is shown escaped.
|
|
44
|
+
*/
|
|
45
|
+
export function UnpublishedAtMergeAlert({ branches, onDismiss, }) {
|
|
46
|
+
const count = `${branches.length} ${branches.length === 1 ? 'branch' : 'branches'}`;
|
|
47
|
+
return (_jsx(Alert, { color: "orange", icon: _jsx(IconAlertTriangle, { size: 16 }), title: `${count} merged with an unfinished submit`,
|
|
48
|
+
// The default clips focus rings at the edge, and scrolls sideways on Tab.
|
|
49
|
+
styles: { message: { overflow: 'visible' } }, "data-testid": "unpublished-at-merge-alert", children: _jsxs(Stack, { gap: 4, children: [_jsx(Text, { size: "sm", children: "Merged on GitHub before the last submit finished, so commits from that submit may not be in the merge. They're still on this deployment's copy of the branch." }), _jsxs(Text, { size: "sm", children: ["Close System health and open the branch from \"", EDITOR_ACTIONS.switchBranch, "\u2026\", where it's read-only, then compare it with the pull request. Make anything that's missing on a new branch, then use \"Dismiss\"."] }), _jsx(Stack, { gap: 4, mt: 4, children: branches.map((branch) => (_jsx(UnpublishedRow, { branch: branch, onDismiss: onDismiss }, branch.dirName))) })] }) }));
|
|
50
|
+
}
|
|
@@ -51,6 +51,8 @@ export interface UseSystemHealthReturn {
|
|
|
51
51
|
purgeDir: (dirName: string) => Promise<void>;
|
|
52
52
|
repairDir: (dirName: string) => Promise<void>;
|
|
53
53
|
markMerged: (branchName: string) => Promise<void>;
|
|
54
|
+
/** Stops listing a branch merged with an unfinished submit, a warning that doesn't come back. */
|
|
55
|
+
dismissUnpublished: (dirName: string) => Promise<void>;
|
|
54
56
|
}
|
|
55
57
|
export declare function useSystemHealth(options: UseSystemHealthOptions): UseSystemHealthReturn;
|
|
56
58
|
export {};
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
* never on the poll.
|
|
13
13
|
*/
|
|
14
14
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
15
|
+
import { useSWRConfig } from 'swr';
|
|
15
16
|
import { notifications } from '@mantine/notifications';
|
|
16
17
|
import { useApiClient } from '../context/index.js';
|
|
18
|
+
import { BRANCHES_KEY } from '../hooks/useBranchesData.js';
|
|
17
19
|
const POLL_INTERVAL_MS = 30_000;
|
|
18
20
|
/**
|
|
19
21
|
* Crash-loop rider window: a worker that fatally exits and restarts
|
|
@@ -24,6 +26,7 @@ const POLL_INTERVAL_MS = 30_000;
|
|
|
24
26
|
const CRASH_LOOP_WINDOW_MS = 30 * 60_000;
|
|
25
27
|
export function useSystemHealth(options) {
|
|
26
28
|
const apiClient = useApiClient();
|
|
29
|
+
const { mutate } = useSWRConfig();
|
|
27
30
|
const [status, setStatus] = useState(null);
|
|
28
31
|
const [statusLoading, setStatusLoading] = useState(false);
|
|
29
32
|
const [isRecentFatalError, setIsRecentFatalError] = useState(false);
|
|
@@ -213,6 +216,23 @@ export function useSystemHealth(options) {
|
|
|
213
216
|
notifications.show({ message, color: 'red' });
|
|
214
217
|
}
|
|
215
218
|
}, [apiClient, refresh]);
|
|
219
|
+
const dismissUnpublished = useCallback(async (dirName) => {
|
|
220
|
+
try {
|
|
221
|
+
const result = await apiClient.admin.dismissUnpublished({ dirName });
|
|
222
|
+
if (!result.ok && result.status !== 409) {
|
|
223
|
+
throw new Error(result.error || 'Failed to dismiss the warning');
|
|
224
|
+
}
|
|
225
|
+
if (!result.ok)
|
|
226
|
+
notifications.show({ message: result.error, color: 'yellow' });
|
|
227
|
+
await refresh();
|
|
228
|
+
// The branch's own "Needs action" comes from the list.
|
|
229
|
+
await mutate(BRANCHES_KEY);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
const message = err instanceof Error ? err.message : 'Failed to dismiss the warning';
|
|
233
|
+
notifications.show({ message, color: 'red' });
|
|
234
|
+
}
|
|
235
|
+
}, [apiClient, mutate, refresh]);
|
|
216
236
|
const repairDuplicateIds = useCallback(async (dirName) => {
|
|
217
237
|
try {
|
|
218
238
|
const result = await apiClient.admin.repairContentDuplicates({ dirName });
|
|
@@ -268,5 +288,6 @@ export function useSystemHealth(options) {
|
|
|
268
288
|
purgeDir,
|
|
269
289
|
repairDir,
|
|
270
290
|
markMerged,
|
|
291
|
+
dismissUnpublished,
|
|
271
292
|
};
|
|
272
293
|
}
|
|
@@ -15,6 +15,8 @@ export interface BranchLockInput {
|
|
|
15
15
|
* way forward is Withdraw and the reason says why Approve is not offered.
|
|
16
16
|
*/
|
|
17
17
|
pullRequestClosed?: boolean;
|
|
18
|
+
/** The server refuses Approve on this branch, so no reason may offer it. */
|
|
19
|
+
approveBlocked?: boolean;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* Which lock applies. `branchWriteBlocked` is the server's own answer (getBranchProtection), so
|
|
@@ -30,9 +30,11 @@ export function describeBranchLock(lock) {
|
|
|
30
30
|
if (lock.pullRequestClosed)
|
|
31
31
|
return CLOSED_PULL_REQUEST_REASON;
|
|
32
32
|
if (lock.branchStatus === 'submitted')
|
|
33
|
-
return lock.reviewing
|
|
34
|
-
? 'This branch is in review.
|
|
35
|
-
:
|
|
33
|
+
return !lock.reviewing
|
|
34
|
+
? 'This branch is in review. Withdraw it to make changes.'
|
|
35
|
+
: lock.approveBlocked
|
|
36
|
+
? 'This branch is in review. Request changes to send it back to its editor.'
|
|
37
|
+
: 'This branch is in review. Approve it, or request changes to send it back to its editor.';
|
|
36
38
|
if (lock.branchStatus === 'approved')
|
|
37
39
|
return lock.reviewing
|
|
38
40
|
? 'This branch is approved. Request changes to send it back to its editor.'
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { BranchStatus } from '../types.js';
|
|
1
|
+
import type { BranchHold, BranchStatus } from '../types.js';
|
|
2
2
|
export interface BranchStatusPresentation {
|
|
3
3
|
label: string;
|
|
4
4
|
color: string;
|
|
@@ -15,3 +15,27 @@ export declare const BRANCH_STATUS_PRESENTATION: Record<BranchStatus, BranchStat
|
|
|
15
15
|
* Returns undefined when there is nothing to show.
|
|
16
16
|
*/
|
|
17
17
|
export declare function branchStatusPresentation(status: string | undefined, isProtected?: boolean): BranchStatusPresentation | undefined;
|
|
18
|
+
/** The badge for a branch something holds, in the bar, on the card and in System health. */
|
|
19
|
+
export declare const HOLD_BADGE: {
|
|
20
|
+
readonly label: "Needs action";
|
|
21
|
+
readonly color: "yellow";
|
|
22
|
+
};
|
|
23
|
+
/** Who is reading a hold, which decides the way forward it can offer them. */
|
|
24
|
+
export interface HoldViewer {
|
|
25
|
+
/** `withdraw`: may withdraw it. `review`: reviews someone else's. `none`: can do neither. */
|
|
26
|
+
role: 'withdraw' | 'review' | 'none';
|
|
27
|
+
isAdmin: boolean;
|
|
28
|
+
/** Reading it in System health, which can't send an admin to itself. */
|
|
29
|
+
inSystemHealth?: boolean;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* What's stuck and what this viewer can do about it. A retargeted base comes from GitHub and is
|
|
33
|
+
* attacker-chosen, so it's shown escaped.
|
|
34
|
+
*/
|
|
35
|
+
export declare function branchHoldMessage(hold: BranchHold, viewer: HoldViewer): string;
|
|
36
|
+
/** Ends `text` as a sentence, for a recorded reason that another sentence follows. */
|
|
37
|
+
export declare function endSentence(text: string): string;
|
|
38
|
+
/** Why a branch in review with a failed sync stays behind its base, after the failure itself. */
|
|
39
|
+
export declare const SYNC_FAILED_IN_REVIEW_NOTE = "Until it syncs, CanopyCMS won't update this pull request from its base.";
|
|
40
|
+
/** The failed-sync line when the worker recorded no reason. An archived branch has nothing to retry. */
|
|
41
|
+
export declare const syncFailedFallback: (isAdmin: boolean, status?: string) => string;
|