@tokenoftrust/storefront-runner 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/storefront/migrations-apps/0001_woozy_lyja.sql +22 -0
- package/apps/storefront/migrations-apps/meta/0001_snapshot.json +990 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +7 -0
- package/apps/storefront/package.json +0 -1
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1735 -132
- package/apps/storefront/src/lib/activity/alerts.ts +428 -0
- package/apps/storefront/src/lib/activity/changeActorAttribution.ts +85 -0
- package/apps/storefront/src/lib/activity/deployVersion.ts +127 -0
- package/apps/storefront/src/lib/activity/ingest.ts +188 -0
- package/apps/storefront/src/lib/activity/ingestAuth.ts +105 -0
- package/apps/storefront/src/lib/activity/killSwitch.ts +80 -0
- package/apps/storefront/src/lib/activity/query.ts +403 -0
- package/apps/storefront/src/lib/activity/recordActivity.ts +105 -0
- package/apps/storefront/src/lib/activity/store.ts +122 -0
- package/apps/storefront/src/lib/activity/uiActor.ts +150 -0
- package/apps/storefront/src/lib/activity/workerCommit.ts +70 -0
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +4 -0
- package/apps/storefront/src/lib/auth/route.ts +44 -4
- package/apps/storefront/src/lib/d1/schema-apps.ts +54 -0
- package/apps/storefront/src/lib/dev/vcBinding.ts +80 -0
- package/apps/storefront/src/lib/env.ts +51 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +15 -8
- package/apps/storefront/src/middleware/index.ts +17 -0
- package/apps/storefront/src/pages/admin/ops-timeline.astro +420 -0
- package/apps/storefront/src/pages/admin.astro +154 -6
- package/apps/storefront/src/pages/api/activity.ts +137 -0
- package/apps/storefront/src/pages/api/admin/activity-alerts.ts +73 -0
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +27 -1
- package/apps/storefront/src/pages/api/apps/admin/install.ts +26 -1
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +25 -1
- package/apps/storefront/src/pages/api/apps/admin/update.ts +24 -1
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +11 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +11 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +20 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +12 -0
- package/apps/storefront/src/pages/api/cache-purge.ts +11 -0
- package/apps/storefront/src/pages/api/dashboard/enter-vendor.ts +30 -0
- package/apps/storefront/src/pages/auth/login.astro +5 -4
- package/apps/storefront/src/pages/cockpit.astro +40 -3
- package/package.json +1 -1
- package/packages/public-runtime/src/activity/README.md +146 -0
- package/packages/public-runtime/src/activity/catalog.ts +501 -0
- package/packages/public-runtime/src/activity/event.ts +168 -0
- package/packages/public-runtime/src/activity/index.ts +16 -0
- package/packages/public-runtime/src/activity/redaction.ts +263 -0
- package/packages/public-runtime/src/candidate-index.ts +13 -0
- package/packages/public-runtime/src/index.ts +6 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +0 -29
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +0 -146
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
*
|
|
7
7
|
* - OPEN CHANGE QUEUE from GET /api/changes ({ current, changes }) with
|
|
8
8
|
* per-change reconcile/compliance state, preview link, head sha, and age.
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
* REJECT (with a note) per change.
|
|
9
|
+
* - ACCEPT TO PREVIEW (POST /api/changes/integrate — b11's accept-to-preview
|
|
10
|
+
* split; advances the shared `preview` aggregate, does NOT go live) and
|
|
11
|
+
* REJECT (with a note) per change. Going live is the aggregate card's
|
|
12
|
+
* exact-digest PUBLISH (POST /api/changes/ship), never a per-change accept.
|
|
12
13
|
* - LIVE STATE: the current live version (live-channel pointer) + the
|
|
13
14
|
* static-republish dispatch status for static-publish tenants (fired /
|
|
14
15
|
* skipped / sink-unbound notice).
|
|
@@ -78,7 +79,6 @@ const staticPublishEnabled = Boolean(
|
|
|
78
79
|
const dispatchConfigured = Boolean((await readEnv("WWW_PUBLISH_DISPATCH_TOKEN"))?.trim());
|
|
79
80
|
|
|
80
81
|
const changesEndpoint = withBase(basePath || "", "/api/changes");
|
|
81
|
-
const acceptEndpoint = withBase(basePath || "", "/api/changes/accept");
|
|
82
82
|
// b11 — the branch-integration lifecycle SPLIT of accept from publish:
|
|
83
83
|
// - integrateEndpoint = b08's "accept = integrate into the protected preview
|
|
84
84
|
// aggregate" (POST /api/changes/integrate → TenantIntegrationQueue.enqueue).
|
|
@@ -97,13 +97,25 @@ const shipEndpoint = withBase(basePath || "", "/api/changes/ship");
|
|
|
97
97
|
// U7 — retire/GC: evict a candidate's ReviewEnvironment + version (rebuildable),
|
|
98
98
|
// DISTINCT from reject (which closes the change). Gated server-side by retireGate.
|
|
99
99
|
const retireEndpoint = withBase(basePath || "", "/api/preview/retire");
|
|
100
|
+
// rux5 — build-on-demand for a synthetic not-built PR row: materialize the PR's
|
|
101
|
+
// hosted preview (INERT — flips no live channel). Gated server-side by buildGate
|
|
102
|
+
// (the ship-on-behalf floor); the endpoint resolves the head sha via forge PR-read
|
|
103
|
+
// when the row supplies only the PR number.
|
|
104
|
+
const buildEndpoint = withBase(basePath || "", "/api/preview/build");
|
|
100
105
|
// dg7 — the ship-operations workspace woven into this tab: versions/promotion
|
|
101
106
|
// history, per-candidate diff, and rollback (through the ONE rollback
|
|
102
107
|
// orchestrator). Read endpoints mirror the change-queue's auth/scope.
|
|
103
108
|
const versionsEndpoint = withBase(basePath || "", "/api/changes/versions");
|
|
104
109
|
const rollbackEndpoint = withBase(basePath || "", "/api/changes/rollback");
|
|
110
|
+
// b17 — branch retention: GET dry-run classification / POST guarded delete.
|
|
111
|
+
// DISTINCT endpoint from retireEndpoint above (branch delete vs artifact evict).
|
|
112
|
+
const branchesEndpoint = withBase(basePath || "", "/api/changes/branches");
|
|
105
113
|
// The client builds `${diffEndpointBase}/<changeId>/diff` per candidate.
|
|
106
114
|
const diffEndpointBase = changesEndpoint;
|
|
115
|
+
// The live store's root on THIS surface (basePath-aware): where the published
|
|
116
|
+
// digest actually serves. Rendered into "already live" copy + the publish
|
|
117
|
+
// ceremony's "View your live store" CTA.
|
|
118
|
+
const liveHref = withBase(basePath || "", "/");
|
|
107
119
|
const domainStatusEndpoint = withBase(basePath || "", "/api/domain/status");
|
|
108
120
|
const domainDispatchEndpoint = withBase(basePath || "", "/api/domain/dispatch");
|
|
109
121
|
// u9: the machine-verifiable apex-cutover readiness gate — the SAME server gate the
|
|
@@ -120,18 +132,20 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
120
132
|
data-agent-scope="publish"
|
|
121
133
|
role="tabpanel"
|
|
122
134
|
data-changes-endpoint={changesEndpoint}
|
|
123
|
-
data-accept-endpoint={acceptEndpoint}
|
|
124
135
|
data-integrate-endpoint={integrateEndpoint}
|
|
125
136
|
data-ship-endpoint={shipEndpoint}
|
|
126
137
|
data-retire-endpoint={retireEndpoint}
|
|
138
|
+
data-build-endpoint={buildEndpoint}
|
|
127
139
|
data-versions-endpoint={versionsEndpoint}
|
|
128
140
|
data-rollback-endpoint={rollbackEndpoint}
|
|
141
|
+
data-branches-endpoint={branchesEndpoint}
|
|
129
142
|
data-diff-endpoint-base={diffEndpointBase}
|
|
130
143
|
data-domain-status-endpoint={domainStatusEndpoint}
|
|
131
144
|
data-domain-dispatch-endpoint={domainDispatchEndpoint}
|
|
132
145
|
data-domain-readiness-endpoint={domainReadinessEndpoint}
|
|
133
146
|
data-repo={tenant.appDomain}
|
|
134
147
|
data-app-domain={tenant.appDomain}
|
|
148
|
+
data-live-href={liveHref}
|
|
135
149
|
data-can-ship={viewerCanShip ? "true" : "false"}
|
|
136
150
|
data-is-owner={viewerIsOwner ? "true" : "false"}
|
|
137
151
|
data-entitled={goLiveEntitled ? "true" : "false"}
|
|
@@ -202,6 +216,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
202
216
|
<span>Published</span>
|
|
203
217
|
<strong data-publish-live-at>—</strong>
|
|
204
218
|
<small>When the live version last changed</small>
|
|
219
|
+
<small data-publish-live-shipped-by></small>
|
|
205
220
|
</div>
|
|
206
221
|
<div class="metric">
|
|
207
222
|
<span>Open changes</span>
|
|
@@ -232,6 +247,23 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
232
247
|
</div>
|
|
233
248
|
<div class="panel-body grid">
|
|
234
249
|
<p class="note publish-status" data-publish-status role="status" aria-live="polite"></p>
|
|
250
|
+
<!-- rux1: forge-listing degraded strip. Shown ONLY when GET /api/changes
|
|
251
|
+
reports `degraded` (the open-PR listing failed) — distinct from the
|
|
252
|
+
genuine empty queue, so a forge blip never reads as "No open changes". -->
|
|
253
|
+
<div class="risk-alert" data-publish-degraded hidden role="alert">
|
|
254
|
+
<div class="risk-alert-main">
|
|
255
|
+
<svg><use href="#i-alert"></use></svg>
|
|
256
|
+
<div>
|
|
257
|
+
<b>Some open changes may not be shown</b>
|
|
258
|
+
<span>
|
|
259
|
+
We couldn't reach the pull-request list, so open changes that haven't been
|
|
260
|
+
built yet may be missing from the queue below. Built candidates are
|
|
261
|
+
unaffected. Refresh to try again.
|
|
262
|
+
</span>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
<p class="note publish-pr-callout" data-publish-pr-callout hidden role="status" aria-live="polite"></p>
|
|
235
267
|
<div class="table-wrap">
|
|
236
268
|
<table class="publish-table">
|
|
237
269
|
<thead>
|
|
@@ -357,6 +389,56 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
357
389
|
</div>
|
|
358
390
|
</div>
|
|
359
391
|
|
|
392
|
+
<!-- b17: branch retention — Admin visibility into the branch-lifecycle
|
|
393
|
+
contract's "Candidate branch retirement" table (P1 items 8/9/11). Reads
|
|
394
|
+
the guarded `candidate_list` classification (protected/active-open/
|
|
395
|
+
stale-open/integrated/closed-or-rejected/orphan) and, for a branch that
|
|
396
|
+
classifies as ready, runs the guarded `candidate_delete` — which
|
|
397
|
+
re-classifies fresh server-side and refuses anything not integrated or
|
|
398
|
+
closed-or-rejected, no matter what this panel last rendered. DISTINCT
|
|
399
|
+
from the "Retire" action in the candidate queue above: retire evicts a
|
|
400
|
+
hosted PREVIEW ARTIFACT (rebuildable); this panel deletes a Git BRANCH
|
|
401
|
+
(transport only, never the durable record — see the note below). An
|
|
402
|
+
orphan is always shown quarantined for review, never one-click deletable. -->
|
|
403
|
+
<div class="panel" data-publish-branches>
|
|
404
|
+
<div class="panel-head">
|
|
405
|
+
<h3><svg><use href="#i-workflows"></use></svg>Branch retention</h3>
|
|
406
|
+
<span class="chip" data-branches-chip>Loading</span>
|
|
407
|
+
</div>
|
|
408
|
+
<div class="panel-body grid">
|
|
409
|
+
<p class="note">
|
|
410
|
+
Cleans up candidate <b>branches</b> once their pull request is merged or
|
|
411
|
+
closed — a different action on a different object than <b>Retire</b> in
|
|
412
|
+
the queue above, which only evicts a hosted preview build (rebuildable
|
|
413
|
+
on demand, never a branch). Every classification below is a fresh
|
|
414
|
+
dry-run; deleting always re-checks eligibility on the server and asks
|
|
415
|
+
you to confirm before removing anything. Orphan branches (no PR record
|
|
416
|
+
at all) are quarantined for your review, never deleted automatically.
|
|
417
|
+
</p>
|
|
418
|
+
<p class="note publish-status" data-branches-status role="status" aria-live="polite"></p>
|
|
419
|
+
<div class="table-wrap">
|
|
420
|
+
<table class="publish-table">
|
|
421
|
+
<thead>
|
|
422
|
+
<tr>
|
|
423
|
+
<th>Branch</th>
|
|
424
|
+
<th>Classification</th>
|
|
425
|
+
<th>PR</th>
|
|
426
|
+
<th>Tip commit</th>
|
|
427
|
+
<th>Age</th>
|
|
428
|
+
<th>Evidence</th>
|
|
429
|
+
<th class="action">Actions</th>
|
|
430
|
+
</tr>
|
|
431
|
+
</thead>
|
|
432
|
+
<tbody data-branches-body>
|
|
433
|
+
<tr data-branches-placeholder>
|
|
434
|
+
<td colspan="7"><span class="subtle">Loading branch classification…</span></td>
|
|
435
|
+
</tr>
|
|
436
|
+
</tbody>
|
|
437
|
+
</table>
|
|
438
|
+
</div>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
|
|
360
442
|
{staticPublishEnabled && (
|
|
361
443
|
<div class="panel" data-publish-domain>
|
|
362
444
|
<div class="panel-head">
|
|
@@ -449,6 +531,121 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
449
531
|
</div>
|
|
450
532
|
</div>
|
|
451
533
|
)}
|
|
534
|
+
|
|
535
|
+
<!-- Accept-to-preview LIFTOFF — the plan confirm + live integration flight +
|
|
536
|
+
outcome ceremony. One dialog, three phases (plan → flight → outcome).
|
|
537
|
+
The flight's stage rail advances ONLY on real signals (the in-flight POST
|
|
538
|
+
plus GET /api/changes polling — includedPrs / activeRun / aggregateSha);
|
|
539
|
+
nothing is simulated. Replaces the old window.confirm() plan popup. -->
|
|
540
|
+
<div class="liftoff-backdrop" data-liftoff hidden>
|
|
541
|
+
<div class="liftoff-card" role="dialog" aria-modal="true" aria-labelledby="liftoff-title" tabindex="-1">
|
|
542
|
+
<div class="liftoff-phase" data-liftoff-plan>
|
|
543
|
+
<p class="liftoff-kicker">Accept to preview</p>
|
|
544
|
+
<h3 class="liftoff-title" id="liftoff-title" data-liftoff-title>Integrate into preview</h3>
|
|
545
|
+
<dl class="liftoff-grid" data-liftoff-grid></dl>
|
|
546
|
+
<p class="note">
|
|
547
|
+
Integrates this candidate into the protected <code>preview</code> aggregate, then
|
|
548
|
+
rebuilds it and re-runs its combined evidence. <b>Nothing goes live</b> —
|
|
549
|
+
publishing the reviewed build stays a separate, explicit step.
|
|
550
|
+
</p>
|
|
551
|
+
<div class="liftoff-actions">
|
|
552
|
+
<button class="btn" type="button" data-liftoff-cancel>Cancel</button>
|
|
553
|
+
<button class="btn primary" type="button" data-liftoff-proceed>
|
|
554
|
+
<svg><use href="#i-check"></use></svg>Integrate into preview
|
|
555
|
+
</button>
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
|
|
559
|
+
<div class="liftoff-phase" data-liftoff-flight hidden>
|
|
560
|
+
<p class="liftoff-kicker">Integration in flight</p>
|
|
561
|
+
<h3 class="liftoff-title" data-liftoff-flight-title>Integrating…</h3>
|
|
562
|
+
<ol class="liftoff-stages">
|
|
563
|
+
<li data-liftoff-stage="merge">
|
|
564
|
+
<span class="liftoff-dot"></span>
|
|
565
|
+
<div><b>Merge</b><span>squash-merge the candidate into <code>preview</code></span></div>
|
|
566
|
+
</li>
|
|
567
|
+
<li data-liftoff-stage="build">
|
|
568
|
+
<span class="liftoff-dot"></span>
|
|
569
|
+
<div><b>Rebuild</b><span>build the combined preview aggregate</span></div>
|
|
570
|
+
</li>
|
|
571
|
+
<li data-liftoff-stage="evidence">
|
|
572
|
+
<span class="liftoff-dot"></span>
|
|
573
|
+
<div><b>Evidence</b><span>re-run the aggregate's combined checks</span></div>
|
|
574
|
+
</li>
|
|
575
|
+
<li data-liftoff-stage="green">
|
|
576
|
+
<span class="liftoff-dot"></span>
|
|
577
|
+
<div><b>Green</b><span>the aggregate becomes the reviewable, shippable build</span></div>
|
|
578
|
+
</li>
|
|
579
|
+
</ol>
|
|
580
|
+
<p class="liftoff-live" data-liftoff-live role="status" aria-live="polite">Working…</p>
|
|
581
|
+
<div class="liftoff-meta">
|
|
582
|
+
<span class="publish-mono" data-liftoff-elapsed>0:00</span>
|
|
583
|
+
<button class="btn" type="button" data-liftoff-hide>Continue in background</button>
|
|
584
|
+
</div>
|
|
585
|
+
</div>
|
|
586
|
+
|
|
587
|
+
<div class="liftoff-phase liftoff-outcome-phase" data-liftoff-outcome hidden>
|
|
588
|
+
<div class="liftoff-seal" data-liftoff-seal aria-hidden="true"></div>
|
|
589
|
+
<h3 class="liftoff-title" data-liftoff-outcome-title></h3>
|
|
590
|
+
<p class="note" data-liftoff-outcome-detail></p>
|
|
591
|
+
<div class="liftoff-actions">
|
|
592
|
+
<a class="btn" data-liftoff-review target="_blank" rel="noopener" hidden>Review the preview build</a>
|
|
593
|
+
<button class="btn primary" type="button" data-liftoff-done>Done</button>
|
|
594
|
+
</div>
|
|
595
|
+
</div>
|
|
596
|
+
|
|
597
|
+
<div class="liftoff-confetti" data-liftoff-confetti aria-hidden="true"></div>
|
|
598
|
+
</div>
|
|
599
|
+
</div>
|
|
600
|
+
|
|
601
|
+
<!-- Publish-live ceremony — the go-live counterpart of the accept liftoff
|
|
602
|
+
(same shell/styles). Plan states the exact digest + no-rebuild contract;
|
|
603
|
+
flight is a single honest in-flight state (one POST, no staged theater);
|
|
604
|
+
outcome celebrates with a real "View your live store" link. Replaces the
|
|
605
|
+
raw window.confirm() publish popup. -->
|
|
606
|
+
<div class="liftoff-backdrop" data-shipoff hidden>
|
|
607
|
+
<div class="liftoff-card" role="dialog" aria-modal="true" aria-labelledby="shipoff-title" tabindex="-1">
|
|
608
|
+
<div class="liftoff-phase" data-shipoff-plan>
|
|
609
|
+
<p class="liftoff-kicker">Publish live</p>
|
|
610
|
+
<h3 class="liftoff-title" id="shipoff-title">Publish the reviewed build to your live store</h3>
|
|
611
|
+
<dl class="liftoff-grid" data-shipoff-grid></dl>
|
|
612
|
+
<p class="note">
|
|
613
|
+
Publishes this <b>exact reviewed digest</b> — no rebuild, nothing rides along.
|
|
614
|
+
Your live store updates in about a second; rolling back re-publishes the prior
|
|
615
|
+
version from the archive.
|
|
616
|
+
</p>
|
|
617
|
+
<div class="liftoff-actions">
|
|
618
|
+
<button class="btn" type="button" data-shipoff-cancel>Cancel</button>
|
|
619
|
+
<button class="btn primary" type="button" data-shipoff-proceed>
|
|
620
|
+
<svg><use href="#i-export"></use></svg>Publish live
|
|
621
|
+
</button>
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
|
|
625
|
+
<div class="liftoff-phase" data-shipoff-flight hidden>
|
|
626
|
+
<p class="liftoff-kicker">Publishing</p>
|
|
627
|
+
<h3 class="liftoff-title">Promoting the reviewed digest…</h3>
|
|
628
|
+
<p class="liftoff-live" data-shipoff-live role="status" aria-live="polite">
|
|
629
|
+
Pinning the exact reviewed build to your live channel…
|
|
630
|
+
</p>
|
|
631
|
+
<div class="liftoff-meta">
|
|
632
|
+
<span class="publish-mono" data-shipoff-elapsed>0:00</span>
|
|
633
|
+
</div>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
<div class="liftoff-phase liftoff-outcome-phase" data-shipoff-outcome hidden>
|
|
637
|
+
<div class="liftoff-seal" data-shipoff-seal aria-hidden="true"></div>
|
|
638
|
+
<h3 class="liftoff-title" data-shipoff-outcome-title></h3>
|
|
639
|
+
<p class="note" data-shipoff-outcome-detail></p>
|
|
640
|
+
<div class="liftoff-actions">
|
|
641
|
+
<a class="btn primary" data-shipoff-view target="_blank" rel="noopener" hidden>View your live store</a>
|
|
642
|
+
<button class="btn" type="button" data-shipoff-done>Done</button>
|
|
643
|
+
</div>
|
|
644
|
+
</div>
|
|
645
|
+
|
|
646
|
+
<div class="liftoff-confetti" data-shipoff-confetti aria-hidden="true"></div>
|
|
647
|
+
</div>
|
|
648
|
+
</div>
|
|
452
649
|
</section>
|
|
453
650
|
|
|
454
651
|
<script is:inline nonce={cspNonce}>
|
|
@@ -464,6 +661,21 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
464
661
|
const queueBody = root.querySelector("[data-publish-queue-body]");
|
|
465
662
|
const queueChip = root.querySelector("[data-publish-queue-chip]");
|
|
466
663
|
const queueCount = root.querySelector("[data-publish-queue-count]");
|
|
664
|
+
const degradedStrip = root.querySelector("[data-publish-degraded]");
|
|
665
|
+
|
|
666
|
+
// rux7 — in-flight UI state the auto-refresh poll must preserve across a
|
|
667
|
+
// re-render. These are the AUTHORITATIVE source of truth (never scraped
|
|
668
|
+
// back out of the DOM): an action's async closure holds a reference to the
|
|
669
|
+
// OLD button, so after a poll swaps the row the completion signal lands on a
|
|
670
|
+
// detached node — reading `disabled`/`data-building` off the fresh DOM would
|
|
671
|
+
// wedge forever. buildPreview/integrate/reject/retire add on start and
|
|
672
|
+
// delete in `finally` (before their own loadQueue), so the next render
|
|
673
|
+
// reflects the true busy set. Open diff panes are the exception: the DOM IS
|
|
674
|
+
// their source of truth (toggleDiff owns open/close), so renderQueue detaches
|
|
675
|
+
// and re-attaches the actual pane node — see renderQueue.
|
|
676
|
+
const inflightBuilds = new Set(); // prNumber currently building
|
|
677
|
+
const busyChanges = new Set(); // changeId with an in-flight integrate/reject/retire
|
|
678
|
+
let aggregatePublishing = false; // the aggregate Publish POST is in flight
|
|
467
679
|
|
|
468
680
|
const setStatus = (el, message, state) => {
|
|
469
681
|
if (!el) return;
|
|
@@ -474,6 +686,14 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
474
686
|
|
|
475
687
|
const shortSha = (sha) => (sha ? String(sha).slice(0, 8) : "—");
|
|
476
688
|
|
|
689
|
+
// Inline mirror of `candidatePrUrl` (lib/preview/candidateDashboard.ts) — an
|
|
690
|
+
// is:inline script can't import a module, so the pure URL shape is mirrored
|
|
691
|
+
// here (same pattern as `shortSha`). The friendly `/preview/<tenant>/pr/<N>`
|
|
692
|
+
// route handles every state (ready, not-built, …), so a row with a PR number
|
|
693
|
+
// always has a working preview link even when `previewUrl` is still null.
|
|
694
|
+
const candidatePrUrl = (prNumber) =>
|
|
695
|
+
prNumber != null ? "/preview/" + encodeURIComponent(appDomain) + "/pr/" + prNumber : null;
|
|
696
|
+
|
|
477
697
|
const age = (iso) => {
|
|
478
698
|
if (!iso) return "—";
|
|
479
699
|
const ms = Date.now() - new Date(iso).getTime();
|
|
@@ -516,10 +736,67 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
516
736
|
return el;
|
|
517
737
|
};
|
|
518
738
|
|
|
739
|
+
// ---- ?pr=N deep link ---------------------------------------------------
|
|
740
|
+
// A deep link like /admin#publish?pr=42 asks us to surface a specific PR's
|
|
741
|
+
// row once the queue has loaded. The param rides in the hash fragment
|
|
742
|
+
// (after the tab anchor) because that's how the admin shell routes tabs;
|
|
743
|
+
// fall back to a real query string too. Resolve exactly once — so the
|
|
744
|
+
// scroll/highlight never re-fires on the reloads that follow every ship
|
|
745
|
+
// action — and until it resolves, keep an honest "not in the queue yet"
|
|
746
|
+
// callout up rather than silently doing nothing.
|
|
747
|
+
|
|
748
|
+
const readRequestedPr = () => {
|
|
749
|
+
const hash = window.location.hash || "";
|
|
750
|
+
const q = hash.indexOf("?");
|
|
751
|
+
let raw = new URLSearchParams(q >= 0 ? hash.slice(q + 1) : "").get("pr");
|
|
752
|
+
if (raw == null) raw = new URLSearchParams(window.location.search).get("pr");
|
|
753
|
+
if (raw == null) return null;
|
|
754
|
+
const n = Number.parseInt(raw, 10);
|
|
755
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
const requestedPr = readRequestedPr();
|
|
759
|
+
let prDeeplinkResolved = false;
|
|
760
|
+
|
|
761
|
+
const applyPrDeeplink = () => {
|
|
762
|
+
if (requestedPr == null || prDeeplinkResolved) return;
|
|
763
|
+
const callout = root.querySelector("[data-publish-pr-callout]");
|
|
764
|
+
const row = queueBody.querySelector('tr[data-pr-number="' + requestedPr + '"]');
|
|
765
|
+
if (!row) {
|
|
766
|
+
if (callout) {
|
|
767
|
+
callout.hidden = false;
|
|
768
|
+
callout.textContent = "PR #" + requestedPr + " isn't in the queue yet.";
|
|
769
|
+
}
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
prDeeplinkResolved = true;
|
|
773
|
+
if (callout) {
|
|
774
|
+
callout.hidden = true;
|
|
775
|
+
callout.textContent = "";
|
|
776
|
+
}
|
|
777
|
+
row.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
778
|
+
row.classList.remove("publish-row-highlight");
|
|
779
|
+
void row.offsetWidth; // restart the flash animation if re-applied
|
|
780
|
+
row.classList.add("publish-row-highlight");
|
|
781
|
+
window.setTimeout(() => row.classList.remove("publish-row-highlight"), 2600);
|
|
782
|
+
};
|
|
783
|
+
|
|
519
784
|
// ---- Open change queue -------------------------------------------------
|
|
520
785
|
|
|
521
786
|
const renderQueue = (payload) => {
|
|
522
787
|
const changes = Array.isArray(payload.changes) ? payload.changes : [];
|
|
788
|
+
// rux1: surface a forge-listing failure as a visible strip — never let a
|
|
789
|
+
// degraded (short) list read as a genuine empty queue.
|
|
790
|
+
if (degradedStrip) degradedStrip.hidden = payload.degraded !== true;
|
|
791
|
+
// rux7: capture any open diff panes BEFORE wiping so a poll re-render
|
|
792
|
+
// re-attaches the exact node (with its already-fetched content) under the
|
|
793
|
+
// matching new row, instead of blowing the expanded pane away. Keyed by
|
|
794
|
+
// changeId (== the diff row's data-publish-diff-row). Wiping detaches them
|
|
795
|
+
// from the DOM but the Map keeps them alive for re-attachment.
|
|
796
|
+
const openDiffNodes = new Map();
|
|
797
|
+
queueBody.querySelectorAll("tr[data-publish-diff-row]").forEach((node) => {
|
|
798
|
+
openDiffNodes.set(node.dataset.publishDiffRow, node);
|
|
799
|
+
});
|
|
523
800
|
queueBody.textContent = "";
|
|
524
801
|
if (queueCount) queueCount.textContent = String(changes.length);
|
|
525
802
|
if (queueChip) {
|
|
@@ -542,6 +819,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
542
819
|
|
|
543
820
|
for (const change of changes) {
|
|
544
821
|
const tr = document.createElement("tr");
|
|
822
|
+
if (change.prNumber != null) tr.dataset.prNumber = String(change.prNumber);
|
|
545
823
|
|
|
546
824
|
// Change id + branch / PR + preview link.
|
|
547
825
|
const idCell = document.createElement("td");
|
|
@@ -552,9 +830,22 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
552
830
|
if (change.branch) meta.push(change.branch);
|
|
553
831
|
if (change.prNumber != null) meta.push("PR #" + change.prNumber);
|
|
554
832
|
if (meta.length) idCell.append(subtle(meta.join(" · ")));
|
|
555
|
-
|
|
833
|
+
// Owner-facing "who did this" — accept takes priority over build (a
|
|
834
|
+
// candidate that's been accepted-to-preview is more meaningfully
|
|
835
|
+
// "acted on" than merely built). Reject never shows here: a rejected
|
|
836
|
+
// candidate is closed and has already left the open queue.
|
|
837
|
+
const actorRecord = change.actors && (change.actors.accept || change.actors.build);
|
|
838
|
+
if (actorRecord) {
|
|
839
|
+
const label = change.actors.accept ? "Accepted by " : "Built by ";
|
|
840
|
+
idCell.append(subtle(label + (actorRecord.name || actorRecord.email)));
|
|
841
|
+
}
|
|
842
|
+
// Prefer the synthesized friendly per-PR route (handles every state);
|
|
843
|
+
// fall back to any server-provided previewUrl for change-first rows
|
|
844
|
+
// that have no PR number yet.
|
|
845
|
+
const previewUrl = candidatePrUrl(change.prNumber) || change.previewUrl;
|
|
846
|
+
if (previewUrl) {
|
|
556
847
|
const preview = document.createElement("a");
|
|
557
|
-
preview.href =
|
|
848
|
+
preview.href = previewUrl;
|
|
558
849
|
preview.target = "_blank";
|
|
559
850
|
preview.rel = "noopener";
|
|
560
851
|
preview.textContent = "Open preview";
|
|
@@ -595,14 +886,59 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
595
886
|
// Actions.
|
|
596
887
|
const actions = document.createElement("td");
|
|
597
888
|
actions.className = "action publish-actions";
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
889
|
+
|
|
890
|
+
// rux5 — a synthetic not-built forge PR row (built:false / status
|
|
891
|
+
// "not-built") has NO built candidate: changeId is null, so the built-only
|
|
892
|
+
// actions below (Diff/Accept/Reject/Retire, all keyed on changeId) do not
|
|
893
|
+
// apply. Its one action is Build preview — materialize the PR's hosted
|
|
894
|
+
// preview on demand. Capability-gated: the button is SHOWN only to a
|
|
895
|
+
// ship-capable operator, and the server (buildGate) re-authorizes — the UI
|
|
896
|
+
// check is UX, not the real gate.
|
|
897
|
+
if (change.built === false || change.status === "not-built") {
|
|
898
|
+
if (canShip) {
|
|
899
|
+
const build = document.createElement("button");
|
|
900
|
+
build.type = "button";
|
|
901
|
+
build.className = "btn primary";
|
|
902
|
+
build.title = "Materialize this PR's hosted preview on demand (does not go live)";
|
|
903
|
+
// rux7: if a build for this PR is mid-flight, a poll re-render must
|
|
904
|
+
// reflect the spinner/disabled state (not a fresh clickable button
|
|
905
|
+
// that would let the operator double-submit). The in-flight closure
|
|
906
|
+
// re-renders on completion, clearing this.
|
|
907
|
+
if (change.prNumber != null && inflightBuilds.has(change.prNumber)) {
|
|
908
|
+
build.textContent = "Building…";
|
|
909
|
+
build.disabled = true;
|
|
910
|
+
build.dataset.building = "true";
|
|
911
|
+
} else {
|
|
912
|
+
build.textContent = "Build preview";
|
|
913
|
+
}
|
|
914
|
+
build.addEventListener("click", () => buildPreview(change, build));
|
|
915
|
+
actions.append(build);
|
|
916
|
+
} else {
|
|
917
|
+
actions.append(subtle("Owner/admin sign-in required to build"));
|
|
918
|
+
}
|
|
919
|
+
tr.append(actions);
|
|
920
|
+
queueBody.append(tr);
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// rux7: while an integrate/reject/retire for this change is in flight,
|
|
925
|
+
// keep its mutating actions disabled across a poll re-render so the
|
|
926
|
+
// operator can't double-submit against a freshly-minted button.
|
|
927
|
+
const changeBusy = busyChanges.has(change.changeId);
|
|
928
|
+
|
|
929
|
+
// rux6 Review: expand the row into a single "review moment" pane —
|
|
930
|
+
// preview iframe + evidence chips + diff + Accept/Reject — so a reviewer
|
|
931
|
+
// never leaves the queue. Supersedes the standalone Diff button (the dg7
|
|
932
|
+
// diff now lives inside the pane, reusing renderDiff). The row's own
|
|
933
|
+
// Accept/Reject/Retire buttons below are unchanged.
|
|
934
|
+
const reviewBtn = document.createElement("button");
|
|
935
|
+
reviewBtn.type = "button";
|
|
936
|
+
reviewBtn.className = "btn";
|
|
937
|
+
reviewBtn.textContent = "Review";
|
|
938
|
+
reviewBtn.setAttribute("aria-expanded", "false");
|
|
939
|
+
reviewBtn.title = "Open one pane to preview, diff, and act on this change";
|
|
940
|
+
reviewBtn.addEventListener("click", () => toggleReview(change, reviewBtn, tr));
|
|
941
|
+
actions.append(reviewBtn);
|
|
606
942
|
// b11 SPLIT: the candidate-queue primary action is now ACCEPT TO PREVIEW
|
|
607
943
|
// (b08 integrate = advance the shared preview aggregate), NOT go-live.
|
|
608
944
|
// Integrate flips NO live channel, so it has NO paywall (entitled) gate —
|
|
@@ -612,7 +948,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
612
948
|
accept.type = "button";
|
|
613
949
|
accept.className = "btn primary";
|
|
614
950
|
accept.textContent = "Accept to preview";
|
|
615
|
-
accept.disabled = !canShip || !change.mergeable;
|
|
951
|
+
accept.disabled = !canShip || !change.mergeable || changeBusy;
|
|
616
952
|
accept.title = !canShip
|
|
617
953
|
? "Requires an owner or admin sign-in"
|
|
618
954
|
: !change.mergeable
|
|
@@ -624,7 +960,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
624
960
|
reject.type = "button";
|
|
625
961
|
reject.className = "btn";
|
|
626
962
|
reject.textContent = "Reject";
|
|
627
|
-
reject.disabled = !canShip;
|
|
963
|
+
reject.disabled = !canShip || changeBusy;
|
|
628
964
|
reject.title = canShip ? "Close this change without publishing" : "Requires an owner or admin sign-in";
|
|
629
965
|
reject.addEventListener("click", () => rejectChange(change, reject));
|
|
630
966
|
actions.append(reject);
|
|
@@ -637,7 +973,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
637
973
|
retire.type = "button";
|
|
638
974
|
retire.className = "btn";
|
|
639
975
|
retire.textContent = "Retire";
|
|
640
|
-
retire.disabled = !canShip;
|
|
976
|
+
retire.disabled = !canShip || changeBusy;
|
|
641
977
|
retire.title = canShip
|
|
642
978
|
? "Evict this preview to reclaim space — rebuildable on demand (not the same as Reject)"
|
|
643
979
|
: "Requires an owner or admin sign-in";
|
|
@@ -646,13 +982,24 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
646
982
|
tr.append(actions);
|
|
647
983
|
|
|
648
984
|
queueBody.append(tr);
|
|
985
|
+
|
|
986
|
+
// rux7: re-attach a diff pane that was open before this re-render. The
|
|
987
|
+
// pane node carries its already-fetched content; restore the expanded
|
|
988
|
+
// state + the "Hide diff" toggle affordance so a poll never collapses it.
|
|
989
|
+
const openDiff = openDiffNodes.get(change.changeId);
|
|
990
|
+
if (openDiff) {
|
|
991
|
+
tr.dataset.changeRowId = change.changeId;
|
|
992
|
+
diffBtn.textContent = "Hide diff";
|
|
993
|
+
tr.after(openDiff);
|
|
994
|
+
}
|
|
649
995
|
}
|
|
650
996
|
};
|
|
651
997
|
|
|
652
|
-
const renderCurrent = (current) => {
|
|
998
|
+
const renderCurrent = (current, shippedBy) => {
|
|
653
999
|
const versionEl = root.querySelector("[data-publish-live-version]");
|
|
654
1000
|
const sourceEl = root.querySelector("[data-publish-live-source]");
|
|
655
1001
|
const atEl = root.querySelector("[data-publish-live-at]");
|
|
1002
|
+
const shippedByEl = root.querySelector("[data-publish-live-shipped-by]");
|
|
656
1003
|
if (current && current.versionId) {
|
|
657
1004
|
if (versionEl) versionEl.textContent = shortSha(current.versionId);
|
|
658
1005
|
if (sourceEl) {
|
|
@@ -661,11 +1008,131 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
661
1008
|
(current.artifactCount != null ? " · " + current.artifactCount + " files" : "");
|
|
662
1009
|
}
|
|
663
1010
|
if (atEl) atEl.textContent = current.createdAt ? age(current.createdAt) : "—";
|
|
1011
|
+
// Owner-facing "who did this" — see changeActorAttribution.ts. Absent for
|
|
1012
|
+
// any version published before this attribution existed, which is honest,
|
|
1013
|
+
// not a bug.
|
|
1014
|
+
if (shippedByEl) {
|
|
1015
|
+
shippedByEl.textContent = shippedBy ? "Shipped by " + (shippedBy.name || shippedBy.email) : "";
|
|
1016
|
+
}
|
|
664
1017
|
} else {
|
|
665
1018
|
if (versionEl) versionEl.textContent = "Not live yet";
|
|
666
1019
|
if (sourceEl) sourceEl.textContent = "Nothing has been published for this store";
|
|
667
1020
|
if (atEl) atEl.textContent = "—";
|
|
1021
|
+
if (shippedByEl) shippedByEl.textContent = "";
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
// ---- rux7: auto-refresh (poll GET /api/changes) ------------------------
|
|
1026
|
+
// Keep the queue + aggregate live without a manual reload: poll on an
|
|
1027
|
+
// interval that BACKS OFF while nothing changes (grow the delay up to a cap)
|
|
1028
|
+
// and SNAPS BACK to the base the moment something does. Pause entirely while
|
|
1029
|
+
// the tab is hidden (document.hidden) — a backgrounded admin tab must not
|
|
1030
|
+
// hammer the endpoint — and refresh promptly on return. A poll re-render
|
|
1031
|
+
// reuses renderQueue/renderAggregate, which already preserve open diff panes
|
|
1032
|
+
// and in-flight actions (inflightBuilds / busyChanges / aggregatePublishing),
|
|
1033
|
+
// and applyPrDeeplink stays a one-time effect (prDeeplinkResolved), so the
|
|
1034
|
+
// ?pr scroll never re-fires on a tick.
|
|
1035
|
+
const POLL_BASE_MS = 8000;
|
|
1036
|
+
const POLL_MAX_MS = 60000;
|
|
1037
|
+
const POLL_GROWTH = 1.6;
|
|
1038
|
+
let pollDelay = POLL_BASE_MS;
|
|
1039
|
+
let pollTimer = null;
|
|
1040
|
+
let pollBusy = false;
|
|
1041
|
+
let lastChangesSignature = null;
|
|
1042
|
+
|
|
1043
|
+
// A compact projection of everything the queue/aggregate render VISIBLY off,
|
|
1044
|
+
// so a no-op poll (identical data) neither re-renders nor interrupts a text
|
|
1045
|
+
// selection — it just backs off. Relative ages are intentionally excluded;
|
|
1046
|
+
// they drift at most one backoff interval and refresh on any real change.
|
|
1047
|
+
const changesSignature = (data) => {
|
|
1048
|
+
const cur = data.current || {};
|
|
1049
|
+
const agg = data.aggregate || {};
|
|
1050
|
+
const run = agg.activeRun || agg.lastRun || {};
|
|
1051
|
+
const plan = data.plan || {};
|
|
1052
|
+
const rows = (Array.isArray(data.changes) ? data.changes : []).map((c) => [
|
|
1053
|
+
c.changeId,
|
|
1054
|
+
c.prNumber,
|
|
1055
|
+
c.status,
|
|
1056
|
+
c.headSha,
|
|
1057
|
+
c.mergeable,
|
|
1058
|
+
c.built,
|
|
1059
|
+
c.lastError || null,
|
|
1060
|
+
!!(c.evidence && c.evidence.promotable),
|
|
1061
|
+
c.actors ? Object.keys(c.actors).sort().join(",") : "",
|
|
1062
|
+
]);
|
|
1063
|
+
return JSON.stringify({
|
|
1064
|
+
degraded: data.degraded === true,
|
|
1065
|
+
current: [cur.versionId || null, cur.createdAt || null, cur.artifactCount ?? null],
|
|
1066
|
+
shippedBy: (data.shippedBy && data.shippedBy.email) || null,
|
|
1067
|
+
aggregate: [
|
|
1068
|
+
agg.queueState || null,
|
|
1069
|
+
agg.aggregateSha || null,
|
|
1070
|
+
run.state || null,
|
|
1071
|
+
run.finishedAt || null,
|
|
1072
|
+
(agg.includedPrs || []).length,
|
|
1073
|
+
],
|
|
1074
|
+
plan: [plan.ok === true, plan.pinnedSha || null, plan.artifactDigest || null, plan.alreadyShipped === true],
|
|
1075
|
+
rows,
|
|
1076
|
+
});
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
const clearPoll = () => {
|
|
1080
|
+
if (pollTimer) {
|
|
1081
|
+
clearTimeout(pollTimer);
|
|
1082
|
+
pollTimer = null;
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
const schedulePoll = () => {
|
|
1087
|
+
clearPoll();
|
|
1088
|
+
if (document.hidden) return; // paused while hidden; visibilitychange resumes
|
|
1089
|
+
pollTimer = setTimeout(runPoll, pollDelay);
|
|
1090
|
+
};
|
|
1091
|
+
|
|
1092
|
+
// One poll tick: fetch, and only re-render when the projection actually
|
|
1093
|
+
// changed. Returns an outcome the caller uses to size the next interval.
|
|
1094
|
+
const pollChanges = async () => {
|
|
1095
|
+
let res;
|
|
1096
|
+
try {
|
|
1097
|
+
res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
|
|
1098
|
+
} catch {
|
|
1099
|
+
return "error"; // network blip — keep the last good view, just back off
|
|
1100
|
+
}
|
|
1101
|
+
if (res.status === 401 || res.status === 403) return "auth"; // session gone
|
|
1102
|
+
const data = await res.json().catch(() => null);
|
|
1103
|
+
if (!res.ok || !data) return "error";
|
|
1104
|
+
const sig = changesSignature(data);
|
|
1105
|
+
if (sig === lastChangesSignature) return "unchanged";
|
|
1106
|
+
lastChangesSignature = sig;
|
|
1107
|
+
renderCurrent(data.current, data.shippedBy);
|
|
1108
|
+
renderQueue(data);
|
|
1109
|
+
applyPrDeeplink();
|
|
1110
|
+
renderAggregate(data);
|
|
1111
|
+
return "changed";
|
|
1112
|
+
};
|
|
1113
|
+
|
|
1114
|
+
const runPoll = async () => {
|
|
1115
|
+
pollTimer = null;
|
|
1116
|
+
if (document.hidden || pollBusy) {
|
|
1117
|
+
schedulePoll();
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
pollBusy = true;
|
|
1121
|
+
let outcome;
|
|
1122
|
+
try {
|
|
1123
|
+
outcome = await pollChanges();
|
|
1124
|
+
} finally {
|
|
1125
|
+
pollBusy = false;
|
|
1126
|
+
}
|
|
1127
|
+
if (outcome === "auth") {
|
|
1128
|
+
clearPoll(); // stop polling — a sign-in is required; a manual refresh retries
|
|
1129
|
+
return;
|
|
668
1130
|
}
|
|
1131
|
+
pollDelay =
|
|
1132
|
+
outcome === "changed"
|
|
1133
|
+
? POLL_BASE_MS
|
|
1134
|
+
: Math.min(POLL_MAX_MS, Math.round(pollDelay * POLL_GROWTH));
|
|
1135
|
+
schedulePoll();
|
|
669
1136
|
};
|
|
670
1137
|
|
|
671
1138
|
const loadQueue = async () => {
|
|
@@ -673,6 +1140,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
673
1140
|
const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
|
|
674
1141
|
if (res.status === 401 || res.status === 403) {
|
|
675
1142
|
if (queueChip) queueChip.textContent = "Sign-in required";
|
|
1143
|
+
if (degradedStrip) degradedStrip.hidden = true;
|
|
676
1144
|
queueBody.textContent = "";
|
|
677
1145
|
const tr = document.createElement("tr");
|
|
678
1146
|
const td = cell(subtle("Sign in as a store owner or admin to view the publish queue."));
|
|
@@ -686,9 +1154,16 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
686
1154
|
setStatus(statusEl, data.error || "Could not load the change queue (" + res.status + ")", "err");
|
|
687
1155
|
return;
|
|
688
1156
|
}
|
|
689
|
-
renderCurrent(data.current);
|
|
1157
|
+
renderCurrent(data.current, data.shippedBy);
|
|
690
1158
|
renderQueue(data);
|
|
1159
|
+
applyPrDeeplink();
|
|
691
1160
|
renderAggregate(data);
|
|
1161
|
+
// rux7: this is fresh authoritative data — resync the poll clock so the
|
|
1162
|
+
// next tick dedupes against it, and reset the cadence to responsive
|
|
1163
|
+
// (a manual refresh / a just-completed action likely means more coming).
|
|
1164
|
+
lastChangesSignature = changesSignature(data);
|
|
1165
|
+
pollDelay = POLL_BASE_MS;
|
|
1166
|
+
schedulePoll();
|
|
692
1167
|
} catch {
|
|
693
1168
|
setStatus(statusEl, "Could not load the change queue — network error", "err");
|
|
694
1169
|
}
|
|
@@ -819,7 +1294,10 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
819
1294
|
if (publishBtn) {
|
|
820
1295
|
const shippable = Boolean(currentPlan);
|
|
821
1296
|
const paywalled = currentPlan && currentPlan.paywall && !currentPlan.paywall.allowed;
|
|
822
|
-
|
|
1297
|
+
// rux7: never re-enable the Publish button while its POST is in flight —
|
|
1298
|
+
// a poll refresh landing mid-publish must not open a double-submit.
|
|
1299
|
+
publishBtn.disabled =
|
|
1300
|
+
aggregatePublishing || !canShip || !shippable || paywalled || currentPlan.alreadyShipped;
|
|
823
1301
|
publishBtn.title = !canShip
|
|
824
1302
|
? "Requires an owner or admin sign-in"
|
|
825
1303
|
: !shippable
|
|
@@ -832,7 +1310,14 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
832
1310
|
}
|
|
833
1311
|
if (publishDetail) {
|
|
834
1312
|
if (currentPlan && currentPlan.alreadyShipped) {
|
|
835
|
-
publishDetail.textContent =
|
|
1313
|
+
publishDetail.textContent =
|
|
1314
|
+
"This reviewed build (" + shortSha(currentPlan.pinnedSha) + ") is already live. ";
|
|
1315
|
+
const view = document.createElement("a");
|
|
1316
|
+
view.href = ds.liveHref || "/";
|
|
1317
|
+
view.target = "_blank";
|
|
1318
|
+
view.rel = "noopener";
|
|
1319
|
+
view.textContent = "View your live store →";
|
|
1320
|
+
publishDetail.append(view);
|
|
836
1321
|
} else if (currentPlan) {
|
|
837
1322
|
publishDetail.textContent =
|
|
838
1323
|
"Publishes the exact reviewed digest " + shortSha(currentPlan.artifactDigest.replace(/^sha256:/, "")) +
|
|
@@ -918,6 +1403,46 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
918
1403
|
// pinned plan, then POSTs { confirmGoLive, expectedAggregateSha,
|
|
919
1404
|
// expectedArtifactDigest } — the reviewed-digest guard: a stale plan (queue
|
|
920
1405
|
// moved / artifact changed) is refused by the server, never shipped blind.
|
|
1406
|
+
// ── Publish-live ceremony (shipoff) ──────────────────────────────────────
|
|
1407
|
+
// The go-live counterpart of the accept liftoff below (shares its shell,
|
|
1408
|
+
// styles, planRow + celebrate helpers — referenced at runtime, defined a few
|
|
1409
|
+
// sections down). One honest in-flight state: ship is a single POST that
|
|
1410
|
+
// promotes the pinned digest, so no staged theater — just plan → publishing →
|
|
1411
|
+
// outcome with a real "View your live store" link.
|
|
1412
|
+
const shipoff = root.querySelector("[data-shipoff]");
|
|
1413
|
+
const shipoffCard = shipoff && shipoff.querySelector(".liftoff-card");
|
|
1414
|
+
const shipoffEls = shipoff
|
|
1415
|
+
? {
|
|
1416
|
+
plan: shipoff.querySelector("[data-shipoff-plan]"),
|
|
1417
|
+
flight: shipoff.querySelector("[data-shipoff-flight]"),
|
|
1418
|
+
outcome: shipoff.querySelector("[data-shipoff-outcome]"),
|
|
1419
|
+
grid: shipoff.querySelector("[data-shipoff-grid]"),
|
|
1420
|
+
proceed: shipoff.querySelector("[data-shipoff-proceed]"),
|
|
1421
|
+
cancel: shipoff.querySelector("[data-shipoff-cancel]"),
|
|
1422
|
+
live: shipoff.querySelector("[data-shipoff-live]"),
|
|
1423
|
+
elapsed: shipoff.querySelector("[data-shipoff-elapsed]"),
|
|
1424
|
+
seal: shipoff.querySelector("[data-shipoff-seal]"),
|
|
1425
|
+
outcomeTitle: shipoff.querySelector("[data-shipoff-outcome-title]"),
|
|
1426
|
+
outcomeDetail: shipoff.querySelector("[data-shipoff-outcome-detail]"),
|
|
1427
|
+
view: shipoff.querySelector("[data-shipoff-view]"),
|
|
1428
|
+
done: shipoff.querySelector("[data-shipoff-done]"),
|
|
1429
|
+
confetti: shipoff.querySelector("[data-shipoff-confetti]"),
|
|
1430
|
+
}
|
|
1431
|
+
: null;
|
|
1432
|
+
let shipoffTimer = null;
|
|
1433
|
+
const closeShipoff = () => {
|
|
1434
|
+
if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
|
|
1435
|
+
if (shipoff) shipoff.hidden = true;
|
|
1436
|
+
if (shipoffEls) shipoffEls.confetti.textContent = "";
|
|
1437
|
+
};
|
|
1438
|
+
const showShipoffPhase = (phase) => {
|
|
1439
|
+
if (!shipoffEls) return;
|
|
1440
|
+
shipoffEls.plan.hidden = phase !== "plan";
|
|
1441
|
+
shipoffEls.flight.hidden = phase !== "flight";
|
|
1442
|
+
shipoffEls.outcome.hidden = phase !== "outcome";
|
|
1443
|
+
if (shipoffCard) shipoffCard.dataset.mode = phase;
|
|
1444
|
+
};
|
|
1445
|
+
|
|
921
1446
|
const buildPublishPlanText = (plan) => {
|
|
922
1447
|
const lines = ["Publish plan (exact reviewed digest — no rebuild):"];
|
|
923
1448
|
lines.push(" tenant: " + (appDomain || repo));
|
|
@@ -929,12 +1454,62 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
929
1454
|
return lines.join("\n");
|
|
930
1455
|
};
|
|
931
1456
|
|
|
932
|
-
const
|
|
1457
|
+
const shortDigest = (digest) => {
|
|
1458
|
+
const d = String(digest || "").replace(/^sha256:/, "");
|
|
1459
|
+
return d ? "sha256:" + d.slice(0, 12) + "…" : "—";
|
|
1460
|
+
};
|
|
1461
|
+
|
|
1462
|
+
const publishAggregate = (button) => {
|
|
933
1463
|
if (!currentPlan) return;
|
|
934
1464
|
const plan = currentPlan;
|
|
935
|
-
if (!
|
|
936
|
-
|
|
1465
|
+
if (!shipoff || !shipoffEls) {
|
|
1466
|
+
// No-markup fallback only — the ceremony IS the confirm now.
|
|
1467
|
+
if (!window.confirm(buildPublishPlanText(plan) + "\n\nPublish live?")) return;
|
|
1468
|
+
runShip(plan, button);
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
shipoffEls.grid.textContent = "";
|
|
1472
|
+
shipoffEls.grid.append(
|
|
1473
|
+
...planRow("Store", appDomain || repo),
|
|
1474
|
+
...planRow("Build", shortSha(plan.pinnedSha), true),
|
|
1475
|
+
...planRow("Digest", shortDigest(plan.artifactDigest), true),
|
|
1476
|
+
...planRow("Changes", (plan.includedPrs || []).length + " included"),
|
|
1477
|
+
...(plan.rollbackTarget
|
|
1478
|
+
? planRow("Rollback to", shortSha(plan.rollbackTarget.aggregateSha), true)
|
|
1479
|
+
: []),
|
|
1480
|
+
);
|
|
1481
|
+
showShipoffPhase("plan");
|
|
1482
|
+
shipoff.hidden = false;
|
|
1483
|
+
shipoffEls.proceed.focus();
|
|
1484
|
+
shipoffEls.cancel.onclick = () => closeShipoff();
|
|
1485
|
+
shipoffEls.done.onclick = () => closeShipoff();
|
|
1486
|
+
shipoffEls.proceed.onclick = () => runShip(plan, button);
|
|
1487
|
+
};
|
|
1488
|
+
|
|
1489
|
+
const runShip = async (plan, button) => {
|
|
1490
|
+
if (button) button.disabled = true;
|
|
1491
|
+
aggregatePublishing = true; // rux7: hold the button disabled across polls
|
|
937
1492
|
setStatus(aggStatusEl, "Publishing the reviewed build (" + shortSha(plan.pinnedSha) + ") live…");
|
|
1493
|
+
const startedAt = Date.now();
|
|
1494
|
+
if (shipoffEls) {
|
|
1495
|
+
showShipoffPhase("flight");
|
|
1496
|
+
shipoffTimer = setInterval(() => {
|
|
1497
|
+
const s = Math.floor((Date.now() - startedAt) / 1000);
|
|
1498
|
+
shipoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
1499
|
+
}, 1000);
|
|
1500
|
+
}
|
|
1501
|
+
const outcome = (state, title, detail, showView) => {
|
|
1502
|
+
if (!shipoffEls) return;
|
|
1503
|
+
if (shipoffTimer) { clearInterval(shipoffTimer); shipoffTimer = null; }
|
|
1504
|
+
shipoffEls.seal.dataset.state = state;
|
|
1505
|
+
shipoffEls.outcomeTitle.textContent = title;
|
|
1506
|
+
shipoffEls.outcomeDetail.textContent = detail;
|
|
1507
|
+
shipoffEls.view.hidden = !showView;
|
|
1508
|
+
if (showView) shipoffEls.view.href = ds.liveHref || "/";
|
|
1509
|
+
showShipoffPhase("outcome");
|
|
1510
|
+
shipoffEls.done.focus();
|
|
1511
|
+
if (state === "ok") celebrate(shipoffEls.confetti);
|
|
1512
|
+
};
|
|
938
1513
|
try {
|
|
939
1514
|
const res = await fetch(ds.shipEndpoint, {
|
|
940
1515
|
method: "POST",
|
|
@@ -955,22 +1530,37 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
955
1530
|
"Published live · build " + shortSha(data.pinnedSha) + " (receipt " + (data.receiptId || "—") + ").",
|
|
956
1531
|
"ok",
|
|
957
1532
|
);
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
setStatus(
|
|
965
|
-
aggStatusEl,
|
|
966
|
-
data.message || data.error || "Publish refused (" + (data.reason || res.status) + ")",
|
|
967
|
-
"err",
|
|
1533
|
+
outcome(
|
|
1534
|
+
"ok",
|
|
1535
|
+
"Your store is live",
|
|
1536
|
+
"Build " + shortSha(data.pinnedSha) + " — the exact digest you reviewed — is now serving" +
|
|
1537
|
+
(data.receiptId ? " (receipt " + data.receiptId + ")." : "."),
|
|
1538
|
+
true,
|
|
968
1539
|
);
|
|
1540
|
+
} else {
|
|
1541
|
+
let message;
|
|
1542
|
+
if (data.state === "promote_failed" || data.state === "record_failed") {
|
|
1543
|
+
// Honest partial-failure truth — recoverable, never a false "shipped".
|
|
1544
|
+
message = data.message || "Publish did not complete (" + data.state + "). Retry.";
|
|
1545
|
+
} else if (data.reason === "paywall") {
|
|
1546
|
+
message = data.message || "Publishing to your live store is part of the paid plan.";
|
|
1547
|
+
} else {
|
|
1548
|
+
message = data.message || data.error || "Publish refused (" + (data.reason || res.status) + ")";
|
|
1549
|
+
}
|
|
1550
|
+
setStatus(aggStatusEl, message, "err");
|
|
1551
|
+
outcome("err", "Publish did not complete", message + " Your live store is unchanged.", false);
|
|
969
1552
|
}
|
|
970
1553
|
} catch {
|
|
971
1554
|
setStatus(aggStatusEl, "Publish failed — network error", "err");
|
|
1555
|
+
outcome(
|
|
1556
|
+
"err",
|
|
1557
|
+
"Publish did not complete",
|
|
1558
|
+
"Network error mid-publish — refresh to see the honest state; your live store serves the last published version.",
|
|
1559
|
+
false,
|
|
1560
|
+
);
|
|
972
1561
|
} finally {
|
|
973
|
-
|
|
1562
|
+
aggregatePublishing = false;
|
|
1563
|
+
if (button) button.disabled = false;
|
|
974
1564
|
loadQueue();
|
|
975
1565
|
loadVersions();
|
|
976
1566
|
}
|
|
@@ -981,35 +1571,207 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
981
1571
|
?.querySelector("[data-aggregate-publish]")
|
|
982
1572
|
?.addEventListener("click", (event) => publishAggregate(event.currentTarget));
|
|
983
1573
|
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
//
|
|
987
|
-
//
|
|
988
|
-
// rebuild + combined-evidence
|
|
989
|
-
// going live is the aggregate card's Publish.
|
|
990
|
-
//
|
|
991
|
-
|
|
1574
|
+
// ── Accept-to-preview LIFTOFF ─────────────────────────────────────────────
|
|
1575
|
+
// The plan confirm + live integration flight + outcome ceremony (replaces
|
|
1576
|
+
// the old window.confirm plan popup). Same b11 contract as before: accept =
|
|
1577
|
+
// `POST /api/changes/integrate` (b08) → integrate into the protected
|
|
1578
|
+
// `preview` aggregate, rebuild + combined-evidence it (b05). Flips NO live
|
|
1579
|
+
// channel — going live is the aggregate card's Publish.
|
|
1580
|
+
//
|
|
1581
|
+
// HONESTY RULE for the stage rail: a stage only advances on a REAL signal —
|
|
1582
|
+
// the outcome POST resolving, or GET /api/changes polling showing the
|
|
1583
|
+
// candidate inside `aggregate.includedPrs` (merge landed) / an `activeRun`
|
|
1584
|
+
// (build+evidence running, statusMessage relayed verbatim). No timers ever
|
|
1585
|
+
// fake progress.
|
|
1586
|
+
const liftoff = root.querySelector("[data-liftoff]");
|
|
1587
|
+
const liftoffCard = liftoff && liftoff.querySelector(".liftoff-card");
|
|
1588
|
+
const liftoffEls = liftoff
|
|
1589
|
+
? {
|
|
1590
|
+
plan: liftoff.querySelector("[data-liftoff-plan]"),
|
|
1591
|
+
flight: liftoff.querySelector("[data-liftoff-flight]"),
|
|
1592
|
+
outcome: liftoff.querySelector("[data-liftoff-outcome]"),
|
|
1593
|
+
title: liftoff.querySelector("[data-liftoff-title]"),
|
|
1594
|
+
grid: liftoff.querySelector("[data-liftoff-grid]"),
|
|
1595
|
+
proceed: liftoff.querySelector("[data-liftoff-proceed]"),
|
|
1596
|
+
cancel: liftoff.querySelector("[data-liftoff-cancel]"),
|
|
1597
|
+
flightTitle: liftoff.querySelector("[data-liftoff-flight-title]"),
|
|
1598
|
+
live: liftoff.querySelector("[data-liftoff-live]"),
|
|
1599
|
+
elapsed: liftoff.querySelector("[data-liftoff-elapsed]"),
|
|
1600
|
+
hide: liftoff.querySelector("[data-liftoff-hide]"),
|
|
1601
|
+
seal: liftoff.querySelector("[data-liftoff-seal]"),
|
|
1602
|
+
outcomeTitle: liftoff.querySelector("[data-liftoff-outcome-title]"),
|
|
1603
|
+
outcomeDetail: liftoff.querySelector("[data-liftoff-outcome-detail]"),
|
|
1604
|
+
review: liftoff.querySelector("[data-liftoff-review]"),
|
|
1605
|
+
done: liftoff.querySelector("[data-liftoff-done]"),
|
|
1606
|
+
confetti: liftoff.querySelector("[data-liftoff-confetti]"),
|
|
1607
|
+
}
|
|
1608
|
+
: null;
|
|
1609
|
+
|
|
1610
|
+
const liftoffStage = (name) =>
|
|
1611
|
+
liftoff && liftoff.querySelector('[data-liftoff-stage="' + name + '"]');
|
|
1612
|
+
const LIFTOFF_STAGES = ["merge", "build", "evidence", "green"];
|
|
1613
|
+
const setStage = (name, state) => {
|
|
1614
|
+
const el = liftoffStage(name);
|
|
1615
|
+
if (el) el.dataset.state = state; // pending | active | done | fail
|
|
1616
|
+
};
|
|
1617
|
+
// Advance the rail so everything before `name` is done and `name` is active.
|
|
1618
|
+
const railTo = (name) => {
|
|
1619
|
+
const idx = LIFTOFF_STAGES.indexOf(name);
|
|
1620
|
+
LIFTOFF_STAGES.forEach((s, i) => {
|
|
1621
|
+
const el = liftoffStage(s);
|
|
1622
|
+
const cur = el && el.dataset.state;
|
|
1623
|
+
if (cur === "fail") return; // never un-fail
|
|
1624
|
+
setStage(s, i < idx ? "done" : i === idx ? "active" : "pending");
|
|
1625
|
+
});
|
|
1626
|
+
};
|
|
1627
|
+
|
|
1628
|
+
let liftoffTimer = null;
|
|
1629
|
+
let liftoffPoll = null;
|
|
1630
|
+
let liftoffOpenedFrom = null;
|
|
1631
|
+
const stopLiftoffLoops = () => {
|
|
1632
|
+
if (liftoffTimer) { clearInterval(liftoffTimer); liftoffTimer = null; }
|
|
1633
|
+
if (liftoffPoll) { clearInterval(liftoffPoll); liftoffPoll = null; }
|
|
1634
|
+
};
|
|
1635
|
+
const closeLiftoff = () => {
|
|
1636
|
+
stopLiftoffLoops();
|
|
1637
|
+
if (liftoff) liftoff.hidden = true;
|
|
1638
|
+
if (liftoffEls) liftoffEls.confetti.textContent = "";
|
|
1639
|
+
if (liftoffOpenedFrom && document.contains(liftoffOpenedFrom)) liftoffOpenedFrom.focus();
|
|
1640
|
+
liftoffOpenedFrom = null;
|
|
1641
|
+
};
|
|
1642
|
+
const showPhase = (phase) => {
|
|
1643
|
+
if (!liftoffEls) return;
|
|
1644
|
+
liftoffEls.plan.hidden = phase !== "plan";
|
|
1645
|
+
liftoffEls.flight.hidden = phase !== "flight";
|
|
1646
|
+
liftoffEls.outcome.hidden = phase !== "outcome";
|
|
1647
|
+
if (liftoffCard) liftoffCard.dataset.mode = phase;
|
|
1648
|
+
};
|
|
1649
|
+
|
|
1650
|
+
const planRow = (dt, dd, mono) => {
|
|
1651
|
+
const t = document.createElement("dt");
|
|
1652
|
+
t.textContent = dt;
|
|
1653
|
+
const d = document.createElement("dd");
|
|
1654
|
+
d.textContent = dd;
|
|
1655
|
+
if (mono) d.className = "publish-mono";
|
|
1656
|
+
return [t, d];
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1659
|
+
const celebrate = (container) => {
|
|
1660
|
+
if (!container) return;
|
|
1661
|
+
if (window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
1662
|
+
const colors = ["#0f9d8f", "#25b8a8", "#f4b942", "#5b8def", "#e2634f"];
|
|
1663
|
+
for (let i = 0; i < 42; i++) {
|
|
1664
|
+
const p = document.createElement("i");
|
|
1665
|
+
p.style.left = Math.random() * 100 + "%";
|
|
1666
|
+
p.style.background = colors[i % colors.length];
|
|
1667
|
+
p.style.animationDelay = Math.random() * 0.35 + "s";
|
|
1668
|
+
p.style.animationDuration = 0.9 + Math.random() * 0.9 + "s";
|
|
1669
|
+
p.style.setProperty("--liftoff-drift", (Math.random() * 160 - 80).toFixed(0) + "px");
|
|
1670
|
+
p.style.setProperty("--liftoff-spin", (Math.random() * 640 - 320).toFixed(0) + "deg");
|
|
1671
|
+
container.append(p);
|
|
1672
|
+
}
|
|
1673
|
+
setTimeout(() => { container.textContent = ""; }, 2400);
|
|
1674
|
+
};
|
|
1675
|
+
|
|
1676
|
+
// The live poll during flight: reflect the server's REAL progress.
|
|
1677
|
+
const startFlightPoll = (change, preFlightSha) => {
|
|
1678
|
+
let inAggregate = false;
|
|
1679
|
+
liftoffPoll = setInterval(async () => {
|
|
1680
|
+
try {
|
|
1681
|
+
const res = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
|
|
1682
|
+
if (!res.ok) return;
|
|
1683
|
+
const data = await res.json();
|
|
1684
|
+
const agg = data.aggregate || {};
|
|
1685
|
+
const included = (agg.includedPrs || []).some((p) => p.changeId === change.changeId);
|
|
1686
|
+
const shaMoved = agg.aggregateSha && agg.aggregateSha !== preFlightSha;
|
|
1687
|
+
const run = agg.activeRun || null;
|
|
1688
|
+
if (!inAggregate && (included || shaMoved)) {
|
|
1689
|
+
inAggregate = true;
|
|
1690
|
+
railTo("build");
|
|
1691
|
+
if (liftoffEls) liftoffEls.live.textContent =
|
|
1692
|
+
"Merged into preview — rebuilding the combined aggregate…";
|
|
1693
|
+
}
|
|
1694
|
+
if (run) {
|
|
1695
|
+
railTo("evidence");
|
|
1696
|
+
if (liftoffEls) liftoffEls.live.textContent =
|
|
1697
|
+
run.statusMessage || "Combined evidence is running…";
|
|
1698
|
+
}
|
|
1699
|
+
} catch { /* polling is best-effort; the POST outcome is authoritative */ }
|
|
1700
|
+
}, 2500);
|
|
1701
|
+
};
|
|
1702
|
+
|
|
1703
|
+
const openLiftoff = (change, button) => {
|
|
1704
|
+
if (!liftoff || !liftoffEls) return false;
|
|
1705
|
+
liftoffOpenedFrom = button || null;
|
|
992
1706
|
const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
"
|
|
999
|
-
|
|
1707
|
+
liftoffEls.title.textContent = "Integrate " + target + " into preview";
|
|
1708
|
+
liftoffEls.grid.textContent = "";
|
|
1709
|
+
liftoffEls.grid.append(
|
|
1710
|
+
...planRow("Store", appDomain || repo),
|
|
1711
|
+
...(change.prNumber != null ? planRow("Pull request", "#" + change.prNumber) : []),
|
|
1712
|
+
...planRow("Change", change.changeId, true),
|
|
1713
|
+
...(change.headSha ? planRow("Head", shortSha(change.headSha), true) : []),
|
|
1000
1714
|
);
|
|
1001
|
-
|
|
1715
|
+
LIFTOFF_STAGES.forEach((s) => setStage(s, "pending"));
|
|
1716
|
+
liftoffEls.live.textContent = "Working…";
|
|
1717
|
+
liftoffEls.elapsed.textContent = "0:00";
|
|
1718
|
+
showPhase("plan");
|
|
1719
|
+
liftoff.hidden = false;
|
|
1720
|
+
liftoffEls.proceed.focus();
|
|
1721
|
+
|
|
1722
|
+
liftoffEls.cancel.onclick = () => closeLiftoff();
|
|
1723
|
+
liftoffEls.done.onclick = () => closeLiftoff();
|
|
1724
|
+
liftoffEls.hide.onclick = () => { liftoff.hidden = true; }; // flight keeps running
|
|
1725
|
+
liftoffEls.proceed.onclick = () => runIntegration(change, button);
|
|
1726
|
+
return true;
|
|
1002
1727
|
};
|
|
1003
1728
|
|
|
1729
|
+
// Esc: close in plan/outcome; during flight just hide (the POST keeps going).
|
|
1730
|
+
// Handles BOTH ceremonies (accept liftoff + publish shipoff).
|
|
1731
|
+
document.addEventListener("keydown", (ev) => {
|
|
1732
|
+
if (ev.key !== "Escape") return;
|
|
1733
|
+
if (liftoff && !liftoff.hidden) {
|
|
1734
|
+
const mode = liftoffCard && liftoffCard.dataset.mode;
|
|
1735
|
+
if (mode === "flight") liftoff.hidden = true;
|
|
1736
|
+
else closeLiftoff();
|
|
1737
|
+
}
|
|
1738
|
+
if (shipoff && !shipoff.hidden) {
|
|
1739
|
+
const mode = shipoffCard && shipoffCard.dataset.mode;
|
|
1740
|
+
if (mode === "flight") shipoff.hidden = true;
|
|
1741
|
+
else closeShipoff();
|
|
1742
|
+
}
|
|
1743
|
+
});
|
|
1744
|
+
|
|
1004
1745
|
// b08 integrate returns an IntegrateOutcome — { ok, queueState, runState,
|
|
1005
1746
|
// aggregateSha, pointerMoved, statusMessage, reason? } — NEVER a bare
|
|
1006
1747
|
// merged:true. Render it honestly: green advances the aggregate; red left it
|
|
1007
1748
|
// exactly where it was and names the failing stage.
|
|
1008
|
-
const
|
|
1009
|
-
|
|
1010
|
-
button.disabled = true;
|
|
1749
|
+
const runIntegration = async (change, button) => {
|
|
1750
|
+
const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
|
|
1751
|
+
if (button) button.disabled = true;
|
|
1752
|
+
busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
|
|
1011
1753
|
setStatus(statusEl, "Integrating " + change.changeId + " into preview…");
|
|
1754
|
+
const startedAt = Date.now();
|
|
1755
|
+
let preFlightSha = null;
|
|
1756
|
+
if (liftoffEls) {
|
|
1757
|
+
showPhase("flight");
|
|
1758
|
+
liftoffEls.flightTitle.textContent = "Integrating " + target + " into preview";
|
|
1759
|
+
railTo("merge");
|
|
1760
|
+
liftoffEls.live.textContent = "Squash-merging the candidate into preview…";
|
|
1761
|
+
liftoffTimer = setInterval(() => {
|
|
1762
|
+
const s = Math.floor((Date.now() - startedAt) / 1000);
|
|
1763
|
+
liftoffEls.elapsed.textContent = Math.floor(s / 60) + ":" + String(s % 60).padStart(2, "0");
|
|
1764
|
+
}, 1000);
|
|
1765
|
+
}
|
|
1012
1766
|
try {
|
|
1767
|
+
// Snapshot the pre-flight aggregate sha so the poll can detect the merge
|
|
1768
|
+
// landing (sha moved) even before includedPrs reflects it.
|
|
1769
|
+
try {
|
|
1770
|
+
const pre = await fetch(ds.changesEndpoint, { headers: { accept: "application/json" } });
|
|
1771
|
+
if (pre.ok) preFlightSha = ((await pre.json()).aggregate || {}).aggregateSha || null;
|
|
1772
|
+
} catch { /* best-effort */ }
|
|
1773
|
+
startFlightPoll(change, preFlightSha);
|
|
1774
|
+
|
|
1013
1775
|
const res = await fetch(ds.integrateEndpoint, {
|
|
1014
1776
|
method: "POST",
|
|
1015
1777
|
headers: {
|
|
@@ -1026,6 +1788,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1026
1788
|
}),
|
|
1027
1789
|
});
|
|
1028
1790
|
const data = await res.json().catch(() => ({}));
|
|
1791
|
+
stopLiftoffLoops();
|
|
1029
1792
|
if (res.ok && data.ok) {
|
|
1030
1793
|
setStatus(
|
|
1031
1794
|
statusEl,
|
|
@@ -1033,38 +1796,91 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1033
1796
|
shortSha(data.aggregateSha) + "). Review the aggregate, then Publish to go live.",
|
|
1034
1797
|
"ok",
|
|
1035
1798
|
);
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
"
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1799
|
+
if (liftoffEls) {
|
|
1800
|
+
LIFTOFF_STAGES.forEach((s) => setStage(s, "done"));
|
|
1801
|
+
liftoffEls.seal.dataset.state = "ok";
|
|
1802
|
+
liftoffEls.outcomeTitle.textContent = "The preview aggregate is green";
|
|
1803
|
+
liftoffEls.outcomeDetail.textContent =
|
|
1804
|
+
target + " is in. Aggregate build " + shortSha(data.aggregateSha) +
|
|
1805
|
+
" passed its combined evidence — this exact build is what you review, and what " +
|
|
1806
|
+
"Publish would take live. Nothing is live yet.";
|
|
1807
|
+
if (data.aggregateSha) {
|
|
1808
|
+
liftoffEls.review.href =
|
|
1809
|
+
"/preview/" + encodeURIComponent(appDomain) + "/rev/" + data.aggregateSha + "/";
|
|
1810
|
+
liftoffEls.review.hidden = false;
|
|
1811
|
+
} else {
|
|
1812
|
+
liftoffEls.review.hidden = true;
|
|
1813
|
+
}
|
|
1814
|
+
if (liftoff.hidden) liftoff.hidden = false; // surface the payoff even if backgrounded
|
|
1815
|
+
showPhase("outcome");
|
|
1816
|
+
liftoffEls.done.focus();
|
|
1817
|
+
celebrate(liftoffEls.confetti);
|
|
1818
|
+
}
|
|
1053
1819
|
} else {
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1820
|
+
let message;
|
|
1821
|
+
let failStage = "merge";
|
|
1822
|
+
if (res.ok && data.queueState === "red") {
|
|
1823
|
+
// The queue ran but the aggregate did not go green — the candidate did
|
|
1824
|
+
// NOT advance the shippable set. Name the honest failing stage.
|
|
1825
|
+
failStage = "evidence";
|
|
1826
|
+
message =
|
|
1827
|
+
"Integration of " + change.changeId + " failed (" + (data.reason || "red") + "): " +
|
|
1828
|
+
(data.statusMessage || "the aggregate did not pass its combined evidence.") +
|
|
1829
|
+
" The aggregate is unchanged.";
|
|
1830
|
+
} else if (data.reason === "not_preview_base") {
|
|
1831
|
+
message =
|
|
1832
|
+
"Not integrated: " + change.changeId + " does not target the preview branch " +
|
|
1833
|
+
"(base is not \"preview\"). " + (data.statusMessage || "");
|
|
1834
|
+
} else {
|
|
1835
|
+
message = data.statusMessage || data.error || "Integrate failed (" + res.status + ")";
|
|
1836
|
+
}
|
|
1837
|
+
setStatus(statusEl, message, "err");
|
|
1838
|
+
if (liftoffEls) {
|
|
1839
|
+
setStage(failStage, "fail");
|
|
1840
|
+
liftoffEls.seal.dataset.state = "err";
|
|
1841
|
+
liftoffEls.outcomeTitle.textContent = "Integration did not land";
|
|
1842
|
+
liftoffEls.outcomeDetail.textContent = message;
|
|
1843
|
+
liftoffEls.review.hidden = true;
|
|
1844
|
+
if (liftoff.hidden) liftoff.hidden = false;
|
|
1845
|
+
showPhase("outcome");
|
|
1846
|
+
liftoffEls.done.focus();
|
|
1847
|
+
}
|
|
1059
1848
|
}
|
|
1060
1849
|
} catch {
|
|
1850
|
+
stopLiftoffLoops();
|
|
1061
1851
|
setStatus(statusEl, "Integrate failed — network error", "err");
|
|
1852
|
+
if (liftoffEls) {
|
|
1853
|
+
liftoffEls.seal.dataset.state = "err";
|
|
1854
|
+
liftoffEls.outcomeTitle.textContent = "Integration did not land";
|
|
1855
|
+
liftoffEls.outcomeDetail.textContent =
|
|
1856
|
+
"Network error mid-flight — refresh the queue to see the aggregate's honest state.";
|
|
1857
|
+
liftoffEls.review.hidden = true;
|
|
1858
|
+
if (liftoff.hidden) liftoff.hidden = false;
|
|
1859
|
+
showPhase("outcome");
|
|
1860
|
+
}
|
|
1062
1861
|
} finally {
|
|
1063
|
-
|
|
1862
|
+
busyChanges.delete(change.changeId);
|
|
1863
|
+
if (button) button.disabled = false;
|
|
1064
1864
|
loadQueue();
|
|
1065
1865
|
}
|
|
1066
1866
|
};
|
|
1067
1867
|
|
|
1868
|
+
const integrateCandidate = (change, button) => {
|
|
1869
|
+
// The liftoff dialog IS the plan confirm now; window.confirm stays only as
|
|
1870
|
+
// the no-dialog fallback (markup missing = fail open to the old popup).
|
|
1871
|
+
if (openLiftoff(change, button)) return;
|
|
1872
|
+
const target = change.prNumber != null ? "PR #" + change.prNumber : change.changeId;
|
|
1873
|
+
const plan =
|
|
1874
|
+
"Accept-to-preview plan:\n tenant: " + (appDomain || repo) +
|
|
1875
|
+
(change.prNumber != null ? "\n PR: #" + change.prNumber : "") +
|
|
1876
|
+
"\n change id: " + change.changeId +
|
|
1877
|
+
(change.headSha ? "\n head sha: " + shortSha(change.headSha) : "") +
|
|
1878
|
+
"\n effect: integrate " + target + " into the preview aggregate, then rebuild + " +
|
|
1879
|
+
"re-evidence it. Does NOT go live — publish the reviewed build separately.";
|
|
1880
|
+
if (!window.confirm(plan + "\n\nProceed?")) return;
|
|
1881
|
+
runIntegration(change, button);
|
|
1882
|
+
};
|
|
1883
|
+
|
|
1068
1884
|
const rejectChange = async (change, button) => {
|
|
1069
1885
|
const reason = window.prompt(
|
|
1070
1886
|
"Reject " + change.changeId + " — add a short note for the submitter:",
|
|
@@ -1075,6 +1891,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1075
1891
|
return;
|
|
1076
1892
|
}
|
|
1077
1893
|
button.disabled = true;
|
|
1894
|
+
busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
|
|
1078
1895
|
setStatus(statusEl, "Rejecting " + change.changeId + "…");
|
|
1079
1896
|
try {
|
|
1080
1897
|
const res = await fetch(
|
|
@@ -1097,6 +1914,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1097
1914
|
} catch {
|
|
1098
1915
|
setStatus(statusEl, "Reject failed — network error", "err");
|
|
1099
1916
|
} finally {
|
|
1917
|
+
busyChanges.delete(change.changeId);
|
|
1100
1918
|
button.disabled = false;
|
|
1101
1919
|
loadQueue();
|
|
1102
1920
|
}
|
|
@@ -1125,6 +1943,7 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1125
1943
|
const retireChange = async (change, button) => {
|
|
1126
1944
|
if (!window.confirm(buildRetirePlan(change) + "\n\nProceed?")) return;
|
|
1127
1945
|
button.disabled = true;
|
|
1946
|
+
busyChanges.add(change.changeId); // rux7: survive a poll re-render as busy
|
|
1128
1947
|
setStatus(statusEl, "Retiring " + change.changeId + "…");
|
|
1129
1948
|
try {
|
|
1130
1949
|
const res = await fetch(ds.retireEndpoint, {
|
|
@@ -1155,67 +1974,271 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1155
1974
|
} catch {
|
|
1156
1975
|
setStatus(statusEl, "Retire failed — network error", "err");
|
|
1157
1976
|
} finally {
|
|
1977
|
+
busyChanges.delete(change.changeId);
|
|
1978
|
+
button.disabled = false;
|
|
1979
|
+
loadQueue();
|
|
1980
|
+
}
|
|
1981
|
+
};
|
|
1982
|
+
|
|
1983
|
+
// ---- rux5: build-on-demand for a synthetic not-built PR ------------------
|
|
1984
|
+
// Mirror of the integrate/retire plan affordances: state EXACTLY what build
|
|
1985
|
+
// does — materialize this PR's hosted preview at its forge head — and that it
|
|
1986
|
+
// is INERT (flips no live channel). POSTs { pr, headSha?, baseSha? }; the row
|
|
1987
|
+
// already carries the head sha GET /api/changes resolved via forge PR-read, and
|
|
1988
|
+
// the server re-resolves it authoritatively when the body omits it. Shows an
|
|
1989
|
+
// in-progress state (disabled + "Building…") while the build runs, then reloads
|
|
1990
|
+
// the queue so the freshly-built PR reappears as a built candidate.
|
|
1991
|
+
|
|
1992
|
+
const buildPreviewPlan = (change) => {
|
|
1993
|
+
const lines = ["Build preview plan:", " tenant: " + (appDomain || repo)];
|
|
1994
|
+
lines.push(" PR: #" + change.prNumber);
|
|
1995
|
+
if (change.headSha) lines.push(" head sha: " + shortSha(change.headSha));
|
|
1996
|
+
lines.push(
|
|
1997
|
+
" effect: materialize this PR's hosted preview at its head — INERT " +
|
|
1998
|
+
"(does NOT go live).",
|
|
1999
|
+
);
|
|
2000
|
+
return lines.join("\n");
|
|
2001
|
+
};
|
|
2002
|
+
|
|
2003
|
+
const buildPreview = async (change, button) => {
|
|
2004
|
+
const label = "PR #" + change.prNumber;
|
|
2005
|
+
if (!window.confirm(buildPreviewPlan(change) + "\n\nBuild the preview now?")) return;
|
|
2006
|
+
button.disabled = true;
|
|
2007
|
+
const originalText = button.textContent;
|
|
2008
|
+
button.textContent = "Building…";
|
|
2009
|
+
button.dataset.building = "true";
|
|
2010
|
+
// rux7: record the in-flight build so a poll re-render preserves the
|
|
2011
|
+
// spinner/disabled state on the freshly-rendered button (cleared in finally).
|
|
2012
|
+
if (change.prNumber != null) inflightBuilds.add(change.prNumber);
|
|
2013
|
+
setStatus(statusEl, "Building preview for " + label + "…");
|
|
2014
|
+
try {
|
|
2015
|
+
const res = await fetch(ds.buildEndpoint, {
|
|
2016
|
+
method: "POST",
|
|
2017
|
+
headers: {
|
|
2018
|
+
"content-type": "application/json",
|
|
2019
|
+
// Mirror of the server-side capability model (UX); the server
|
|
2020
|
+
// re-derives capability (buildGate) and stays authoritative.
|
|
2021
|
+
"x-tot-capability": "ship-on-behalf",
|
|
2022
|
+
},
|
|
2023
|
+
body: JSON.stringify({
|
|
2024
|
+
pr: change.prNumber,
|
|
2025
|
+
headSha: change.headSha || undefined,
|
|
2026
|
+
baseSha: change.baseSha || undefined,
|
|
2027
|
+
}),
|
|
2028
|
+
});
|
|
2029
|
+
const data = await res.json().catch(() => ({}));
|
|
2030
|
+
if (res.ok && data.ok) {
|
|
2031
|
+
setStatus(
|
|
2032
|
+
statusEl,
|
|
2033
|
+
"Built preview for " + label +
|
|
2034
|
+
" — it now appears as a built change in the queue.",
|
|
2035
|
+
"ok",
|
|
2036
|
+
);
|
|
2037
|
+
} else if (data && data.ok === false) {
|
|
2038
|
+
// reconcileCandidate returned a blocked/failed candidate (e.g. a safety
|
|
2039
|
+
// finding with no override) — the honest reason, never a false success.
|
|
2040
|
+
setStatus(
|
|
2041
|
+
statusEl,
|
|
2042
|
+
"Build did not complete for " + label + ": " +
|
|
2043
|
+
((data.errors && data.errors.join("; ")) || data.error || "blocked"),
|
|
2044
|
+
"err",
|
|
2045
|
+
);
|
|
2046
|
+
} else {
|
|
2047
|
+
setStatus(statusEl, data.error || "Build failed (" + res.status + ")", "err");
|
|
2048
|
+
}
|
|
2049
|
+
} catch {
|
|
2050
|
+
setStatus(statusEl, "Build failed — network error", "err");
|
|
2051
|
+
} finally {
|
|
2052
|
+
if (change.prNumber != null) inflightBuilds.delete(change.prNumber);
|
|
1158
2053
|
button.disabled = false;
|
|
2054
|
+
button.textContent = originalText;
|
|
2055
|
+
delete button.dataset.building;
|
|
1159
2056
|
loadQueue();
|
|
1160
2057
|
}
|
|
1161
2058
|
};
|
|
1162
2059
|
|
|
1163
|
-
// ----
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
2060
|
+
// ---- rux6: unified review pane -----------------------------------------
|
|
2061
|
+
// "One review moment": expanding a candidate row reveals a single pane with
|
|
2062
|
+
// the live preview (iframed SAME-ORIGIN — the /preview/<tenant>/pr/<N> route
|
|
2063
|
+
// serves `frame-ancestors 'self'` + `X-Frame-Options: SAMEORIGIN`, so a
|
|
2064
|
+
// same-origin admin embed is permitted; a cross-origin embed is refused,
|
|
2065
|
+
// which is why the pane also always offers an "Open in new tab" link), the
|
|
2066
|
+
// evidence chips the row already carries (change.evidence), the dg7 diff
|
|
2067
|
+
// (reusing renderDiff below — GET /api/changes/<id>/diff, which never invents
|
|
2068
|
+
// a diff for a non-renderable candidate), and the SAME Accept-to-preview /
|
|
2069
|
+
// Reject actions. The reviewer never leaves the queue to review and act; the
|
|
2070
|
+
// server stays authoritative for every action (this is UI consolidation only).
|
|
1169
2071
|
|
|
1170
2072
|
const diffBase = ds.diffEndpointBase;
|
|
1171
2073
|
|
|
1172
|
-
const
|
|
2074
|
+
const reviewRowFor = (tr) => {
|
|
1173
2075
|
const next = tr.nextElementSibling;
|
|
1174
|
-
return next && next.dataset && next.dataset.
|
|
2076
|
+
return next && next.dataset && next.dataset.publishReviewRow === tr.dataset.changeRowId
|
|
1175
2077
|
? next
|
|
1176
2078
|
: null;
|
|
1177
2079
|
};
|
|
1178
2080
|
|
|
1179
|
-
const
|
|
1180
|
-
const existing =
|
|
2081
|
+
const closeReview = (tr, button) => {
|
|
2082
|
+
const existing = reviewRowFor(tr);
|
|
1181
2083
|
if (existing) existing.remove();
|
|
1182
|
-
if (button)
|
|
2084
|
+
if (button) {
|
|
2085
|
+
button.textContent = "Review";
|
|
2086
|
+
button.setAttribute("aria-expanded", "false");
|
|
2087
|
+
}
|
|
1183
2088
|
};
|
|
1184
2089
|
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
2090
|
+
// Evidence chips from the data the row already carries (change.evidence):
|
|
2091
|
+
// an overall promotable verdict + one chip per reported check.
|
|
2092
|
+
const renderEvidenceChips = (container, change) => {
|
|
2093
|
+
const checks = (change.evidence && change.evidence.checks) || [];
|
|
2094
|
+
const promotable = Boolean(change.evidence && change.evidence.promotable);
|
|
2095
|
+
container.append(
|
|
2096
|
+
promotable ? chip("Checks passed", "green") : chip("Checks blocked", "amber"),
|
|
2097
|
+
);
|
|
2098
|
+
for (const c of checks) {
|
|
2099
|
+
const tone = c.status === "pass" ? "green" : c.status === "fail" ? "red" : "amber";
|
|
2100
|
+
const label = (c.label || c.id || "check") + (c.status && c.status !== "pass" ? " · " + c.status : "");
|
|
2101
|
+
const el = chip(label, tone);
|
|
2102
|
+
if (c.detail) el.title = c.detail;
|
|
2103
|
+
container.append(el);
|
|
1190
2104
|
}
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
2105
|
+
if (!checks.length) container.append(subtle("No checks reported yet"));
|
|
2106
|
+
};
|
|
2107
|
+
|
|
2108
|
+
// Build the split pane. Returns the pane node + the diff container so the
|
|
2109
|
+
// caller can populate the diff asynchronously (reusing renderDiff).
|
|
2110
|
+
const buildReviewPane = (change) => {
|
|
2111
|
+
const pane = document.createElement("div");
|
|
2112
|
+
pane.className = "review-pane";
|
|
2113
|
+
|
|
2114
|
+
// Preview column — same-origin iframe of the friendly per-PR route (it
|
|
2115
|
+
// handles every state: ready render, or the not-built page). Falls back to
|
|
2116
|
+
// any server-provided previewUrl for a change-first row with no PR number.
|
|
2117
|
+
const previewUrl = candidatePrUrl(change.prNumber) || change.previewUrl;
|
|
2118
|
+
const previewCol = document.createElement("div");
|
|
2119
|
+
previewCol.className = "review-preview";
|
|
2120
|
+
const pHead = document.createElement("div");
|
|
2121
|
+
pHead.className = "review-pane-head";
|
|
2122
|
+
const pTitle = document.createElement("b");
|
|
2123
|
+
pTitle.textContent = "Preview";
|
|
2124
|
+
pHead.append(pTitle);
|
|
2125
|
+
if (previewUrl) {
|
|
2126
|
+
const openLink = document.createElement("a");
|
|
2127
|
+
openLink.href = previewUrl;
|
|
2128
|
+
openLink.target = "_blank";
|
|
2129
|
+
openLink.rel = "noopener";
|
|
2130
|
+
openLink.textContent = "Open in new tab ↗";
|
|
2131
|
+
pHead.append(openLink);
|
|
2132
|
+
}
|
|
2133
|
+
previewCol.append(pHead);
|
|
2134
|
+
if (previewUrl) {
|
|
2135
|
+
const frame = document.createElement("iframe");
|
|
2136
|
+
frame.className = "review-frame";
|
|
2137
|
+
frame.src = previewUrl;
|
|
2138
|
+
frame.loading = "lazy";
|
|
2139
|
+
frame.title =
|
|
2140
|
+
change.prNumber != null
|
|
2141
|
+
? "Preview of PR #" + change.prNumber
|
|
2142
|
+
: "Preview of " + change.changeId;
|
|
2143
|
+
previewCol.append(frame);
|
|
2144
|
+
} else {
|
|
2145
|
+
previewCol.append(subtle("No preview is available for this change yet."));
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
// Detail column — evidence chips + diff + the same Accept/Reject actions.
|
|
2149
|
+
const detailCol = document.createElement("div");
|
|
2150
|
+
detailCol.className = "review-detail";
|
|
2151
|
+
|
|
2152
|
+
const evSection = document.createElement("div");
|
|
2153
|
+
evSection.className = "review-section";
|
|
2154
|
+
const evTitle = document.createElement("b");
|
|
2155
|
+
evTitle.textContent = "Checks";
|
|
2156
|
+
const evChips = document.createElement("div");
|
|
2157
|
+
evChips.className = "review-chips";
|
|
2158
|
+
renderEvidenceChips(evChips, change);
|
|
2159
|
+
evSection.append(evTitle, evChips);
|
|
2160
|
+
detailCol.append(evSection);
|
|
2161
|
+
|
|
2162
|
+
const diffSection = document.createElement("div");
|
|
2163
|
+
diffSection.className = "review-section";
|
|
2164
|
+
const diffTitle = document.createElement("b");
|
|
2165
|
+
diffTitle.textContent = "Diff";
|
|
2166
|
+
const diffTarget = document.createElement("div");
|
|
2167
|
+
diffTarget.className = "review-diff-target";
|
|
2168
|
+
diffTarget.append(subtle("Loading the diff…"));
|
|
2169
|
+
diffSection.append(diffTitle, diffTarget);
|
|
2170
|
+
detailCol.append(diffSection);
|
|
2171
|
+
|
|
2172
|
+
// The SAME actions as the row — same handlers, same gates. Not a new authz
|
|
2173
|
+
// path: integrateCandidate/rejectChange POST to the same endpoints, and the
|
|
2174
|
+
// server re-authorizes.
|
|
2175
|
+
const actionRow = document.createElement("div");
|
|
2176
|
+
actionRow.className = "review-actions";
|
|
2177
|
+
const accept = document.createElement("button");
|
|
2178
|
+
accept.type = "button";
|
|
2179
|
+
accept.className = "btn primary";
|
|
2180
|
+
accept.textContent = "Accept to preview";
|
|
2181
|
+
accept.disabled = !canShip || !change.mergeable;
|
|
2182
|
+
accept.title = !canShip
|
|
2183
|
+
? "Requires an owner or admin sign-in"
|
|
2184
|
+
: !change.mergeable
|
|
2185
|
+
? "This change is not ready to integrate"
|
|
2186
|
+
: "Integrate this change into the preview aggregate (does not go live)";
|
|
2187
|
+
accept.addEventListener("click", () => integrateCandidate(change, accept));
|
|
2188
|
+
const reject = document.createElement("button");
|
|
2189
|
+
reject.type = "button";
|
|
2190
|
+
reject.className = "btn";
|
|
2191
|
+
reject.textContent = "Reject";
|
|
2192
|
+
reject.disabled = !canShip;
|
|
2193
|
+
reject.title = canShip
|
|
2194
|
+
? "Close this change without publishing"
|
|
2195
|
+
: "Requires an owner or admin sign-in";
|
|
2196
|
+
reject.addEventListener("click", () => rejectChange(change, reject));
|
|
2197
|
+
actionRow.append(accept, reject);
|
|
2198
|
+
detailCol.append(actionRow);
|
|
2199
|
+
|
|
2200
|
+
pane.append(previewCol, detailCol);
|
|
2201
|
+
return { pane, diffTarget };
|
|
2202
|
+
};
|
|
2203
|
+
|
|
2204
|
+
const loadReviewDiff = async (change, diffTarget) => {
|
|
1200
2205
|
try {
|
|
1201
2206
|
const res = await fetch(diffBase + "/" + encodeURIComponent(change.changeId) + "/diff", {
|
|
1202
2207
|
headers: { accept: "application/json" },
|
|
1203
2208
|
});
|
|
1204
2209
|
const data = await res.json().catch(() => ({}));
|
|
2210
|
+
diffTarget.textContent = "";
|
|
1205
2211
|
if (!res.ok) {
|
|
1206
|
-
|
|
2212
|
+
diffTarget.append(subtle(data.error || "Could not load the diff (" + res.status + ")"));
|
|
1207
2213
|
} else {
|
|
1208
|
-
renderDiff(
|
|
2214
|
+
renderDiff(diffTarget, data.diff || {});
|
|
1209
2215
|
}
|
|
1210
|
-
button.textContent = "Hide diff";
|
|
1211
2216
|
} catch {
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
} finally {
|
|
1215
|
-
button.disabled = false;
|
|
2217
|
+
diffTarget.textContent = "";
|
|
2218
|
+
diffTarget.append(subtle("Could not load the diff — network error"));
|
|
1216
2219
|
}
|
|
1217
2220
|
};
|
|
1218
2221
|
|
|
2222
|
+
const toggleReview = (change, button, tr) => {
|
|
2223
|
+
if (!tr.dataset.changeRowId) tr.dataset.changeRowId = change.changeId;
|
|
2224
|
+
if (reviewRowFor(tr)) {
|
|
2225
|
+
closeReview(tr, button);
|
|
2226
|
+
return;
|
|
2227
|
+
}
|
|
2228
|
+
const detailTr = document.createElement("tr");
|
|
2229
|
+
detailTr.dataset.publishReviewRow = tr.dataset.changeRowId;
|
|
2230
|
+
const td = document.createElement("td");
|
|
2231
|
+
td.colSpan = 6;
|
|
2232
|
+
td.className = "publish-review-cell";
|
|
2233
|
+
const { pane, diffTarget } = buildReviewPane(change);
|
|
2234
|
+
td.append(pane);
|
|
2235
|
+
detailTr.append(td);
|
|
2236
|
+
tr.after(detailTr);
|
|
2237
|
+
button.textContent = "Hide review";
|
|
2238
|
+
button.setAttribute("aria-expanded", "true");
|
|
2239
|
+
loadReviewDiff(change, diffTarget);
|
|
2240
|
+
};
|
|
2241
|
+
|
|
1219
2242
|
const CHANGE_MARK = { added: "+", changed: "~", removed: "−" };
|
|
1220
2243
|
|
|
1221
2244
|
const renderDiff = (td, diff) => {
|
|
@@ -1308,15 +2331,17 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1308
2331
|
versionsChip.className = "chip" + (versions.length ? " blue" : "");
|
|
1309
2332
|
}
|
|
1310
2333
|
|
|
1311
|
-
// Static tenants: per-version rollback is
|
|
1312
|
-
// can't enumerate the S3 versions archive digest
|
|
2334
|
+
// Static tenants (rux13): per-version rollback is an HONEST ONE-DISPATCH.
|
|
2335
|
+
// The console can't enumerate the S3 versions archive digest, so it dispatches
|
|
2336
|
+
// by the tenant version id and CI resolves the digest + re-publishes — it
|
|
2337
|
+
// completes via CI, not instantly (never implied one-click).
|
|
1313
2338
|
if (versionsRollbackNote) {
|
|
1314
2339
|
if (rollbackMode === "static-archive") {
|
|
1315
2340
|
versionsRollbackNote.hidden = false;
|
|
1316
2341
|
versionsRollbackNote.textContent =
|
|
1317
|
-
"This store publishes to a static public site. Rolling back
|
|
1318
|
-
"
|
|
1319
|
-
"
|
|
2342
|
+
"This store publishes to a static public site. Rolling back DISPATCHES a CI job that " +
|
|
2343
|
+
"re-publishes the prior version from the S3 versions archive — it completes via CI, not " +
|
|
2344
|
+
"instantly. The store keeps serving the current version until the re-publish is verified live.";
|
|
1320
2345
|
} else {
|
|
1321
2346
|
versionsRollbackNote.hidden = true;
|
|
1322
2347
|
}
|
|
@@ -1375,13 +2400,14 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1375
2400
|
const rollbackBtn = document.createElement("button");
|
|
1376
2401
|
rollbackBtn.type = "button";
|
|
1377
2402
|
rollbackBtn.className = "btn";
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
rollbackBtn.
|
|
2403
|
+
const isStatic = rollbackMode === "static-archive";
|
|
2404
|
+
// Honest label: a static rollback completes via CI, not instantly (rux13).
|
|
2405
|
+
rollbackBtn.textContent = isStatic ? "Roll back via CI" : "Roll back";
|
|
2406
|
+
rollbackBtn.disabled = !canShip;
|
|
1381
2407
|
rollbackBtn.title = !canShip
|
|
1382
2408
|
? "Requires an owner or ship-on-behalf sign-in"
|
|
1383
|
-
:
|
|
1384
|
-
? "
|
|
2409
|
+
: isStatic
|
|
2410
|
+
? "Dispatch a rollback to this version — completes via CI (not instant)"
|
|
1385
2411
|
: "Roll the live site back to this version";
|
|
1386
2412
|
rollbackBtn.addEventListener("click", () => rollbackVersion(v, rollbackBtn));
|
|
1387
2413
|
act.append(rollbackBtn);
|
|
@@ -1449,20 +2475,28 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1449
2475
|
};
|
|
1450
2476
|
|
|
1451
2477
|
const rollbackVersion = async (version, button) => {
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
)
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
2478
|
+
// Static rollback (rux13) is an honest one-DISPATCH — completes via CI, not
|
|
2479
|
+
// instantly; the confirm/status copy must never imply an instant flip.
|
|
2480
|
+
const isStatic = rollbackMode === "static-archive";
|
|
2481
|
+
const confirmMsg = isStatic
|
|
2482
|
+
? "Roll the static public site back to version " + shortSha(version.versionId) +
|
|
2483
|
+
"? This DISPATCHES a rollback that completes via CI (not instantly) — the store keeps " +
|
|
2484
|
+
"serving the current version until CI re-publishes the prior one."
|
|
2485
|
+
: "Roll the live store back to version " + shortSha(version.versionId) +
|
|
2486
|
+
"? This publishes that prior version to the live site.";
|
|
2487
|
+
if (!window.confirm(confirmMsg)) return;
|
|
1460
2488
|
button.disabled = true;
|
|
1461
|
-
setStatus(
|
|
2489
|
+
setStatus(
|
|
2490
|
+
versionsStatus,
|
|
2491
|
+
isStatic
|
|
2492
|
+
? "Dispatching rollback to " + shortSha(version.versionId) + " (completes via CI)…"
|
|
2493
|
+
: "Rolling back to " + shortSha(version.versionId) + "…",
|
|
2494
|
+
);
|
|
1462
2495
|
try {
|
|
2496
|
+
// The console dispatches by version id only; a static prior version's
|
|
2497
|
+
// archive digest lives only in S3 (CI resolves it). Pass a digest ONLY when
|
|
2498
|
+
// we honestly have one (never for a static prior — its digest is always null).
|
|
1463
2499
|
const payload = { targetVersionId: version.versionId };
|
|
1464
|
-
// A static tenant additionally needs the archive digest — the button is
|
|
1465
|
-
// disabled in that mode, but pass it through when we honestly have it.
|
|
1466
2500
|
if (version.digest) payload.targetDigest = version.digest;
|
|
1467
2501
|
const res = await fetch(ds.rollbackEndpoint, {
|
|
1468
2502
|
method: "POST",
|
|
@@ -1714,6 +2748,184 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1714
2748
|
domainAction({ action: "rollback" }, event.currentTarget);
|
|
1715
2749
|
});
|
|
1716
2750
|
|
|
2751
|
+
// ---- b17: branch retention ----------------------------------------------
|
|
2752
|
+
// Renders the GET /api/changes/branches dry-run classification (already
|
|
2753
|
+
// projected server-side by branchRetentionView.ts — this script only
|
|
2754
|
+
// paints it, same division of labor as renderQueue/renderVersions above)
|
|
2755
|
+
// and drives the guarded delete. The server (candidate_delete, b13)
|
|
2756
|
+
// re-classifies fresh on every delete, so the confirm below is the HUMAN
|
|
2757
|
+
// gate, not the safety guarantee — the safety guarantee is server-side.
|
|
2758
|
+
|
|
2759
|
+
const branchesPanel = root.querySelector("[data-publish-branches]");
|
|
2760
|
+
const branchesBody = branchesPanel?.querySelector("[data-branches-body]");
|
|
2761
|
+
const branchesChip = branchesPanel?.querySelector("[data-branches-chip]");
|
|
2762
|
+
const branchesStatus = branchesPanel?.querySelector("[data-branches-status]");
|
|
2763
|
+
|
|
2764
|
+
const BRANCH_TONE = {
|
|
2765
|
+
protected: "",
|
|
2766
|
+
"active-open": "green",
|
|
2767
|
+
"stale-open": "amber",
|
|
2768
|
+
integrated: "blue",
|
|
2769
|
+
"closed-or-rejected": "blue",
|
|
2770
|
+
orphan: "red",
|
|
2771
|
+
};
|
|
2772
|
+
|
|
2773
|
+
const buildCleanupPlan = (row) => {
|
|
2774
|
+
return [
|
|
2775
|
+
"Delete branch plan:",
|
|
2776
|
+
" repo: " + (appDomain || repo),
|
|
2777
|
+
" branch: " + row.ref,
|
|
2778
|
+
" tip sha: " + row.shortSha,
|
|
2779
|
+
" classification: " + row.classificationLabel,
|
|
2780
|
+
" reason: " + row.reason,
|
|
2781
|
+
"",
|
|
2782
|
+
"This deletes the Git BRANCH only — it never evicts a hosted preview build",
|
|
2783
|
+
"(that is Retire, a separate action above). The server re-classifies this",
|
|
2784
|
+
"branch fresh before deleting; a moved tip or a branch that is no longer",
|
|
2785
|
+
"integrated/closed-or-rejected is refused, not silently skipped.",
|
|
2786
|
+
].join("\n");
|
|
2787
|
+
};
|
|
2788
|
+
|
|
2789
|
+
const cleanupBranch = async (row, button) => {
|
|
2790
|
+
if (!window.confirm(buildCleanupPlan(row) + "\n\nDelete this branch?")) return;
|
|
2791
|
+
button.disabled = true;
|
|
2792
|
+
setStatus(branchesStatus, "Deleting " + row.shortRef + "…");
|
|
2793
|
+
try {
|
|
2794
|
+
const res = await fetch(ds.branchesEndpoint, {
|
|
2795
|
+
method: "POST",
|
|
2796
|
+
headers: { "content-type": "application/json", "x-tot-capability": "ship-on-behalf" },
|
|
2797
|
+
body: JSON.stringify({ ref: row.ref, expectedSha: row.sha }),
|
|
2798
|
+
});
|
|
2799
|
+
const data = await res.json().catch(() => ({}));
|
|
2800
|
+
if (res.ok && data.ok) {
|
|
2801
|
+
setStatus(
|
|
2802
|
+
branchesStatus,
|
|
2803
|
+
data.alreadyAbsent
|
|
2804
|
+
? row.shortRef + " was already gone (nothing to delete)."
|
|
2805
|
+
: "Deleted " + row.shortRef + ".",
|
|
2806
|
+
"ok",
|
|
2807
|
+
);
|
|
2808
|
+
} else {
|
|
2809
|
+
setStatus(branchesStatus, data.error || "Delete refused (" + res.status + ")", "err");
|
|
2810
|
+
}
|
|
2811
|
+
} catch {
|
|
2812
|
+
setStatus(branchesStatus, "Delete failed — network error", "err");
|
|
2813
|
+
} finally {
|
|
2814
|
+
button.disabled = false;
|
|
2815
|
+
loadBranches();
|
|
2816
|
+
}
|
|
2817
|
+
};
|
|
2818
|
+
|
|
2819
|
+
const renderBranches = (view) => {
|
|
2820
|
+
if (!branchesBody) return;
|
|
2821
|
+
const rows = Array.isArray(view.rows) ? view.rows : [];
|
|
2822
|
+
if (branchesChip) {
|
|
2823
|
+
const needsReview = rows.filter((r) => r.quarantine || r.eligibleForDelete).length;
|
|
2824
|
+
branchesChip.textContent = needsReview
|
|
2825
|
+
? needsReview + " to review"
|
|
2826
|
+
: rows.length
|
|
2827
|
+
? rows.length + " branches"
|
|
2828
|
+
: "0 branches";
|
|
2829
|
+
branchesChip.className = "chip" + (needsReview ? " amber" : "");
|
|
2830
|
+
}
|
|
2831
|
+
branchesBody.textContent = "";
|
|
2832
|
+
if (!rows.length) {
|
|
2833
|
+
const tr = document.createElement("tr");
|
|
2834
|
+
const td = cell(subtle("No candidate branches found for this repo."));
|
|
2835
|
+
td.colSpan = 7;
|
|
2836
|
+
tr.append(td);
|
|
2837
|
+
branchesBody.append(tr);
|
|
2838
|
+
return;
|
|
2839
|
+
}
|
|
2840
|
+
|
|
2841
|
+
for (const row of rows) {
|
|
2842
|
+
const tr = document.createElement("tr");
|
|
2843
|
+
|
|
2844
|
+
const branchCell = document.createElement("td");
|
|
2845
|
+
const branchStrong = document.createElement("strong");
|
|
2846
|
+
branchStrong.textContent = row.shortRef;
|
|
2847
|
+
branchCell.append(branchStrong, subtle(row.ref + " @ " + row.shortSha));
|
|
2848
|
+
tr.append(branchCell);
|
|
2849
|
+
|
|
2850
|
+
tr.append(cell(chip(row.classificationLabel, BRANCH_TONE[row.classification] || "")));
|
|
2851
|
+
|
|
2852
|
+
const prCell = document.createElement("td");
|
|
2853
|
+
if (row.prNumber != null) {
|
|
2854
|
+
// Link to the INTERNAL friendly PR route (/preview/<tenant>/pr/<N>), never
|
|
2855
|
+
// the server-provided `row.prUrl` — that is the raw Gitea forge html_url the
|
|
2856
|
+
// MCP candidate_list projection emits (<forge-host>/<org>/<repo>/pulls/N),
|
|
2857
|
+
// and surfacing a raw forge link to an owner is exactly the boundary
|
|
2858
|
+
// noGiteaLinks.test.ts guards (the source scan can't catch a host that
|
|
2859
|
+
// arrives in data, so we refuse it here at render). Mirrors the candidate
|
|
2860
|
+
// queue, which already builds its preview link this way.
|
|
2861
|
+
const prHref = candidatePrUrl(row.prNumber);
|
|
2862
|
+
if (prHref) {
|
|
2863
|
+
const a = document.createElement("a");
|
|
2864
|
+
a.href = prHref;
|
|
2865
|
+
a.target = "_blank";
|
|
2866
|
+
a.rel = "noopener";
|
|
2867
|
+
a.textContent = "PR #" + row.prNumber;
|
|
2868
|
+
prCell.append(a);
|
|
2869
|
+
} else {
|
|
2870
|
+
prCell.append(document.createTextNode("PR #" + row.prNumber));
|
|
2871
|
+
}
|
|
2872
|
+
prCell.append(subtle((row.prState || "") + (row.integratedIntoAggregate ? " · in aggregate" : "")));
|
|
2873
|
+
} else {
|
|
2874
|
+
prCell.append(subtle("No PR record"));
|
|
2875
|
+
}
|
|
2876
|
+
tr.append(prCell);
|
|
2877
|
+
|
|
2878
|
+
tr.append(cell(row.commitTimestampDisplay));
|
|
2879
|
+
tr.append(cell(row.ageLabel));
|
|
2880
|
+
tr.append(cell(row.evidenceSummary));
|
|
2881
|
+
|
|
2882
|
+
const actionsCell = document.createElement("td");
|
|
2883
|
+
actionsCell.className = "action";
|
|
2884
|
+
if (row.quarantine) {
|
|
2885
|
+
actionsCell.append(chip("Needs owner review", "red"));
|
|
2886
|
+
} else if (row.eligibleForDelete) {
|
|
2887
|
+
const btn = document.createElement("button");
|
|
2888
|
+
btn.type = "button";
|
|
2889
|
+
btn.className = "btn";
|
|
2890
|
+
btn.disabled = !canShip;
|
|
2891
|
+
btn.title = canShip ? "" : "Requires an owner/admin sign-in.";
|
|
2892
|
+
btn.textContent = "Clean up";
|
|
2893
|
+
btn.addEventListener("click", () => cleanupBranch(row, btn));
|
|
2894
|
+
actionsCell.append(btn);
|
|
2895
|
+
} else {
|
|
2896
|
+
actionsCell.append(subtle("Keep"));
|
|
2897
|
+
}
|
|
2898
|
+
tr.append(actionsCell);
|
|
2899
|
+
|
|
2900
|
+
branchesBody.append(tr);
|
|
2901
|
+
}
|
|
2902
|
+
};
|
|
2903
|
+
|
|
2904
|
+
const loadBranches = async () => {
|
|
2905
|
+
if (!branchesBody) return;
|
|
2906
|
+
try {
|
|
2907
|
+
const res = await fetch(ds.branchesEndpoint, { headers: { accept: "application/json" } });
|
|
2908
|
+
if (res.status === 401 || res.status === 403) {
|
|
2909
|
+
if (branchesChip) branchesChip.textContent = "Sign-in required";
|
|
2910
|
+
branchesBody.textContent = "";
|
|
2911
|
+
const tr = document.createElement("tr");
|
|
2912
|
+
const td = cell(subtle("Sign in as a store owner or admin to view branch retention."));
|
|
2913
|
+
td.colSpan = 7;
|
|
2914
|
+
tr.append(td);
|
|
2915
|
+
branchesBody.append(tr);
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2918
|
+
const data = await res.json().catch(() => ({}));
|
|
2919
|
+
if (!res.ok) {
|
|
2920
|
+
setStatus(branchesStatus, data.error || "Could not load branch classification (" + res.status + ")", "err");
|
|
2921
|
+
return;
|
|
2922
|
+
}
|
|
2923
|
+
renderBranches(data.report || {});
|
|
2924
|
+
} catch {
|
|
2925
|
+
setStatus(branchesStatus, "Could not load branch classification — network error", "err");
|
|
2926
|
+
}
|
|
2927
|
+
};
|
|
2928
|
+
|
|
1717
2929
|
// ---- Wiring ------------------------------------------------------------
|
|
1718
2930
|
|
|
1719
2931
|
root.querySelector("[data-publish-refresh]")?.addEventListener("click", () => {
|
|
@@ -1721,11 +2933,24 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1721
2933
|
loadQueue();
|
|
1722
2934
|
loadVersions();
|
|
1723
2935
|
loadDomain();
|
|
2936
|
+
loadBranches();
|
|
1724
2937
|
});
|
|
1725
2938
|
|
|
2939
|
+
// rux7: pause polling while hidden; on return, refresh immediately (loadQueue
|
|
2940
|
+
// re-renders + resyncs the poll clock) rather than waiting out the interval.
|
|
2941
|
+
document.addEventListener("visibilitychange", () => {
|
|
2942
|
+
if (document.hidden) {
|
|
2943
|
+
clearPoll();
|
|
2944
|
+
} else {
|
|
2945
|
+
loadQueue();
|
|
2946
|
+
}
|
|
2947
|
+
});
|
|
2948
|
+
|
|
2949
|
+
// loadQueue starts the poll cadence via its tail (schedulePoll).
|
|
1726
2950
|
loadQueue();
|
|
1727
2951
|
loadVersions();
|
|
1728
2952
|
loadDomain();
|
|
2953
|
+
loadBranches();
|
|
1729
2954
|
})();
|
|
1730
2955
|
</script>
|
|
1731
2956
|
|
|
@@ -1763,11 +2988,331 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1763
2988
|
color: var(--admin-red);
|
|
1764
2989
|
}
|
|
1765
2990
|
|
|
2991
|
+
/* ?pr=N deep link: "not in the queue" callout + row flash */
|
|
2992
|
+
.publish-pr-callout {
|
|
2993
|
+
border: 1px solid var(--admin-line-strong);
|
|
2994
|
+
border-left: 3px solid var(--admin-amber);
|
|
2995
|
+
background: var(--admin-surface-soft);
|
|
2996
|
+
padding: 8px 12px;
|
|
2997
|
+
border-radius: var(--admin-radius-sm);
|
|
2998
|
+
color: var(--admin-ink);
|
|
2999
|
+
}
|
|
3000
|
+
|
|
3001
|
+
@keyframes publish-row-flash {
|
|
3002
|
+
0%,
|
|
3003
|
+
25% {
|
|
3004
|
+
background-color: var(--admin-teal-soft);
|
|
3005
|
+
}
|
|
3006
|
+
100% {
|
|
3007
|
+
background-color: transparent;
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
|
|
3011
|
+
.publish-row-highlight > td {
|
|
3012
|
+
animation: publish-row-flash 2.4s ease-out;
|
|
3013
|
+
}
|
|
3014
|
+
|
|
3015
|
+
@media (prefers-reduced-motion: reduce) {
|
|
3016
|
+
.publish-row-highlight > td {
|
|
3017
|
+
animation: none;
|
|
3018
|
+
background-color: var(--admin-teal-soft);
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
|
|
1766
3022
|
.publish-error-detail {
|
|
1767
3023
|
color: var(--admin-red);
|
|
1768
3024
|
font-size: 12px;
|
|
1769
3025
|
}
|
|
1770
3026
|
|
|
3027
|
+
/* ── Accept-to-preview LIFTOFF ─────────────────────────────────────────── */
|
|
3028
|
+
.liftoff-backdrop {
|
|
3029
|
+
position: fixed;
|
|
3030
|
+
inset: 0;
|
|
3031
|
+
z-index: 60;
|
|
3032
|
+
display: grid;
|
|
3033
|
+
place-items: center;
|
|
3034
|
+
padding: 24px;
|
|
3035
|
+
background: color-mix(in srgb, var(--admin-ink) 32%, transparent);
|
|
3036
|
+
backdrop-filter: blur(6px);
|
|
3037
|
+
-webkit-backdrop-filter: blur(6px);
|
|
3038
|
+
}
|
|
3039
|
+
|
|
3040
|
+
/* `display: grid` above beats the UA's [hidden] rule — restate it, or the
|
|
3041
|
+
invisible backdrop click-blocks the entire page (caught by live test). */
|
|
3042
|
+
.liftoff-backdrop[hidden] {
|
|
3043
|
+
display: none;
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
.liftoff-card {
|
|
3047
|
+
position: relative;
|
|
3048
|
+
width: min(520px, 100%);
|
|
3049
|
+
max-height: min(84vh, 640px);
|
|
3050
|
+
overflow: auto;
|
|
3051
|
+
border: 1px solid var(--admin-line-strong);
|
|
3052
|
+
border-radius: var(--admin-radius, 12px);
|
|
3053
|
+
background: var(--admin-surface);
|
|
3054
|
+
box-shadow: 0 24px 80px rgba(8, 32, 30, 0.35);
|
|
3055
|
+
padding: 24px 26px;
|
|
3056
|
+
animation: liftoff-in 0.22s ease-out;
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
@keyframes liftoff-in {
|
|
3060
|
+
from { transform: translateY(10px) scale(0.985); opacity: 0; }
|
|
3061
|
+
to { transform: none; opacity: 1; }
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3064
|
+
/* In-flight shimmer: a live gradient beam along the card's top edge. */
|
|
3065
|
+
.liftoff-card::before {
|
|
3066
|
+
content: "";
|
|
3067
|
+
position: absolute;
|
|
3068
|
+
inset: 0 0 auto 0;
|
|
3069
|
+
height: 3px;
|
|
3070
|
+
border-radius: inherit;
|
|
3071
|
+
opacity: 0;
|
|
3072
|
+
background: linear-gradient(90deg, transparent, var(--admin-teal, #0f9d8f), #f4b942, var(--admin-teal, #0f9d8f), transparent);
|
|
3073
|
+
background-size: 220% 100%;
|
|
3074
|
+
transition: opacity 0.3s ease;
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
.liftoff-card[data-mode="flight"]::before {
|
|
3078
|
+
opacity: 1;
|
|
3079
|
+
animation: liftoff-beam 1.6s linear infinite;
|
|
3080
|
+
}
|
|
3081
|
+
|
|
3082
|
+
@keyframes liftoff-beam {
|
|
3083
|
+
from { background-position: 200% 0; }
|
|
3084
|
+
to { background-position: -20% 0; }
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
.liftoff-kicker {
|
|
3088
|
+
margin: 0 0 2px;
|
|
3089
|
+
font-size: 11px;
|
|
3090
|
+
font-weight: 700;
|
|
3091
|
+
letter-spacing: 0.08em;
|
|
3092
|
+
text-transform: uppercase;
|
|
3093
|
+
color: var(--admin-teal, #0f9d8f);
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
.liftoff-title {
|
|
3097
|
+
margin: 0 0 14px;
|
|
3098
|
+
font-size: 19px;
|
|
3099
|
+
line-height: 1.25;
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
.liftoff-grid {
|
|
3103
|
+
display: grid;
|
|
3104
|
+
grid-template-columns: auto 1fr;
|
|
3105
|
+
gap: 6px 16px;
|
|
3106
|
+
margin: 0 0 12px;
|
|
3107
|
+
padding: 12px 14px;
|
|
3108
|
+
border: 1px solid var(--admin-line-strong);
|
|
3109
|
+
border-radius: var(--admin-radius-sm);
|
|
3110
|
+
background: var(--admin-surface-soft);
|
|
3111
|
+
}
|
|
3112
|
+
|
|
3113
|
+
.liftoff-grid dt {
|
|
3114
|
+
color: var(--admin-muted);
|
|
3115
|
+
font-size: 12px;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
.liftoff-grid dd {
|
|
3119
|
+
margin: 0;
|
|
3120
|
+
font-size: 13px;
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
.liftoff-actions {
|
|
3124
|
+
display: flex;
|
|
3125
|
+
justify-content: flex-end;
|
|
3126
|
+
gap: 8px;
|
|
3127
|
+
margin-top: 16px;
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
.liftoff-stages {
|
|
3131
|
+
list-style: none;
|
|
3132
|
+
margin: 6px 0 4px;
|
|
3133
|
+
padding: 0;
|
|
3134
|
+
display: grid;
|
|
3135
|
+
gap: 0;
|
|
3136
|
+
}
|
|
3137
|
+
|
|
3138
|
+
.liftoff-stages li {
|
|
3139
|
+
position: relative;
|
|
3140
|
+
display: flex;
|
|
3141
|
+
gap: 12px;
|
|
3142
|
+
align-items: flex-start;
|
|
3143
|
+
padding: 9px 0;
|
|
3144
|
+
}
|
|
3145
|
+
|
|
3146
|
+
/* Connecting rail between the dots. */
|
|
3147
|
+
.liftoff-stages li + li::before {
|
|
3148
|
+
content: "";
|
|
3149
|
+
position: absolute;
|
|
3150
|
+
left: 10px;
|
|
3151
|
+
top: -8px;
|
|
3152
|
+
height: 16px;
|
|
3153
|
+
width: 2px;
|
|
3154
|
+
background: var(--admin-line-strong);
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
.liftoff-stages li > div {
|
|
3158
|
+
display: grid;
|
|
3159
|
+
gap: 1px;
|
|
3160
|
+
}
|
|
3161
|
+
|
|
3162
|
+
.liftoff-stages b {
|
|
3163
|
+
font-size: 13px;
|
|
3164
|
+
}
|
|
3165
|
+
|
|
3166
|
+
.liftoff-stages span:not(.liftoff-dot) {
|
|
3167
|
+
font-size: 12px;
|
|
3168
|
+
color: var(--admin-muted);
|
|
3169
|
+
}
|
|
3170
|
+
|
|
3171
|
+
.liftoff-dot {
|
|
3172
|
+
flex: none;
|
|
3173
|
+
width: 21px;
|
|
3174
|
+
height: 21px;
|
|
3175
|
+
margin-top: 1px;
|
|
3176
|
+
border-radius: 50%;
|
|
3177
|
+
border: 2px solid var(--admin-line-strong);
|
|
3178
|
+
display: grid;
|
|
3179
|
+
place-items: center;
|
|
3180
|
+
font-size: 12px;
|
|
3181
|
+
font-weight: 700;
|
|
3182
|
+
color: #fff;
|
|
3183
|
+
background: transparent;
|
|
3184
|
+
transition: all 0.25s ease;
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
.liftoff-stages li[data-state="active"] .liftoff-dot {
|
|
3188
|
+
border-color: var(--admin-teal, #0f9d8f);
|
|
3189
|
+
box-shadow: 0 0 0 0 color-mix(in srgb, var(--admin-teal, #0f9d8f) 45%, transparent);
|
|
3190
|
+
animation: liftoff-pulse 1.3s ease-out infinite;
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
.liftoff-stages li[data-state="active"] b {
|
|
3194
|
+
color: var(--admin-teal, #0f9d8f);
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
@keyframes liftoff-pulse {
|
|
3198
|
+
from { box-shadow: 0 0 0 0 color-mix(in srgb, var(--admin-teal, #0f9d8f) 45%, transparent); }
|
|
3199
|
+
to { box-shadow: 0 0 0 10px transparent; }
|
|
3200
|
+
}
|
|
3201
|
+
|
|
3202
|
+
.liftoff-stages li[data-state="done"] .liftoff-dot {
|
|
3203
|
+
border-color: var(--admin-green);
|
|
3204
|
+
background: var(--admin-green);
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
.liftoff-stages li[data-state="done"] .liftoff-dot::after {
|
|
3208
|
+
content: "✓";
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
.liftoff-stages li[data-state="fail"] .liftoff-dot {
|
|
3212
|
+
border-color: var(--admin-red);
|
|
3213
|
+
background: var(--admin-red);
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
.liftoff-stages li[data-state="fail"] .liftoff-dot::after {
|
|
3217
|
+
content: "✕";
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
.liftoff-live {
|
|
3221
|
+
margin: 8px 0 0;
|
|
3222
|
+
min-height: 18px;
|
|
3223
|
+
font-size: 13px;
|
|
3224
|
+
color: var(--admin-ink);
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
.liftoff-meta {
|
|
3228
|
+
display: flex;
|
|
3229
|
+
justify-content: space-between;
|
|
3230
|
+
align-items: center;
|
|
3231
|
+
margin-top: 14px;
|
|
3232
|
+
color: var(--admin-muted);
|
|
3233
|
+
}
|
|
3234
|
+
|
|
3235
|
+
.liftoff-outcome-phase {
|
|
3236
|
+
text-align: center;
|
|
3237
|
+
padding-top: 6px;
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
.liftoff-outcome-phase .liftoff-actions {
|
|
3241
|
+
justify-content: center;
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
.liftoff-seal {
|
|
3245
|
+
width: 56px;
|
|
3246
|
+
height: 56px;
|
|
3247
|
+
margin: 4px auto 12px;
|
|
3248
|
+
border-radius: 50%;
|
|
3249
|
+
display: grid;
|
|
3250
|
+
place-items: center;
|
|
3251
|
+
font-size: 28px;
|
|
3252
|
+
font-weight: 700;
|
|
3253
|
+
color: #fff;
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
.liftoff-seal[data-state="ok"] {
|
|
3257
|
+
background: var(--admin-green);
|
|
3258
|
+
animation: liftoff-seal-pop 0.45s cubic-bezier(0.2, 1.6, 0.4, 1);
|
|
3259
|
+
}
|
|
3260
|
+
|
|
3261
|
+
.liftoff-seal[data-state="ok"]::after {
|
|
3262
|
+
content: "✓";
|
|
3263
|
+
}
|
|
3264
|
+
|
|
3265
|
+
.liftoff-seal[data-state="err"] {
|
|
3266
|
+
background: var(--admin-red);
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
.liftoff-seal[data-state="err"]::after {
|
|
3270
|
+
content: "✕";
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
@keyframes liftoff-seal-pop {
|
|
3274
|
+
from { transform: scale(0.4); opacity: 0; }
|
|
3275
|
+
60% { transform: scale(1.12); }
|
|
3276
|
+
to { transform: none; opacity: 1; }
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
.liftoff-confetti {
|
|
3280
|
+
pointer-events: none;
|
|
3281
|
+
position: absolute;
|
|
3282
|
+
inset: 0;
|
|
3283
|
+
overflow: hidden;
|
|
3284
|
+
border-radius: inherit;
|
|
3285
|
+
}
|
|
3286
|
+
|
|
3287
|
+
.liftoff-confetti i {
|
|
3288
|
+
position: absolute;
|
|
3289
|
+
top: -12px;
|
|
3290
|
+
width: 7px;
|
|
3291
|
+
height: 11px;
|
|
3292
|
+
border-radius: 2px;
|
|
3293
|
+
opacity: 0;
|
|
3294
|
+
animation-name: liftoff-confetti-fall;
|
|
3295
|
+
animation-timing-function: ease-in;
|
|
3296
|
+
animation-fill-mode: forwards;
|
|
3297
|
+
}
|
|
3298
|
+
|
|
3299
|
+
@keyframes liftoff-confetti-fall {
|
|
3300
|
+
0% { opacity: 1; transform: translate(0, 0) rotate(0deg); }
|
|
3301
|
+
100% {
|
|
3302
|
+
opacity: 0.9;
|
|
3303
|
+
transform: translate(var(--liftoff-drift, 0px), 660px) rotate(var(--liftoff-spin, 240deg));
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
@media (prefers-reduced-motion: reduce) {
|
|
3308
|
+
.liftoff-card,
|
|
3309
|
+
.liftoff-card[data-mode="flight"]::before,
|
|
3310
|
+
.liftoff-stages li[data-state="active"] .liftoff-dot,
|
|
3311
|
+
.liftoff-seal[data-state="ok"] {
|
|
3312
|
+
animation: none;
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
|
|
1771
3316
|
.domain-connect-controls {
|
|
1772
3317
|
display: grid;
|
|
1773
3318
|
gap: 8px;
|
|
@@ -1901,11 +3446,69 @@ const domainReadinessEndpoint = withBase(basePath || "", "/api/domain/readiness"
|
|
|
1901
3446
|
min-width: 780px;
|
|
1902
3447
|
}
|
|
1903
3448
|
|
|
1904
|
-
|
|
3449
|
+
/* rux6: unified review pane */
|
|
3450
|
+
.publish-review-cell {
|
|
1905
3451
|
padding: 12px 14px;
|
|
1906
3452
|
background: var(--admin-surface-soft);
|
|
1907
3453
|
}
|
|
1908
3454
|
|
|
3455
|
+
.review-pane {
|
|
3456
|
+
display: grid;
|
|
3457
|
+
grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
|
|
3458
|
+
gap: 16px;
|
|
3459
|
+
align-items: start;
|
|
3460
|
+
}
|
|
3461
|
+
|
|
3462
|
+
@media (max-width: 900px) {
|
|
3463
|
+
.review-pane {
|
|
3464
|
+
grid-template-columns: 1fr;
|
|
3465
|
+
}
|
|
3466
|
+
}
|
|
3467
|
+
|
|
3468
|
+
.review-preview {
|
|
3469
|
+
display: grid;
|
|
3470
|
+
gap: 8px;
|
|
3471
|
+
min-width: 0;
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
.review-pane-head {
|
|
3475
|
+
display: flex;
|
|
3476
|
+
align-items: center;
|
|
3477
|
+
justify-content: space-between;
|
|
3478
|
+
gap: 10px;
|
|
3479
|
+
}
|
|
3480
|
+
|
|
3481
|
+
.review-frame {
|
|
3482
|
+
width: 100%;
|
|
3483
|
+
height: 420px;
|
|
3484
|
+
border: 1px solid var(--admin-line-strong);
|
|
3485
|
+
border-radius: var(--admin-radius-sm);
|
|
3486
|
+
background: var(--admin-surface);
|
|
3487
|
+
}
|
|
3488
|
+
|
|
3489
|
+
.review-detail {
|
|
3490
|
+
display: grid;
|
|
3491
|
+
gap: 14px;
|
|
3492
|
+
min-width: 0;
|
|
3493
|
+
}
|
|
3494
|
+
|
|
3495
|
+
.review-section {
|
|
3496
|
+
display: grid;
|
|
3497
|
+
gap: 6px;
|
|
3498
|
+
}
|
|
3499
|
+
|
|
3500
|
+
.review-chips {
|
|
3501
|
+
display: flex;
|
|
3502
|
+
flex-wrap: wrap;
|
|
3503
|
+
gap: 6px;
|
|
3504
|
+
}
|
|
3505
|
+
|
|
3506
|
+
.review-actions {
|
|
3507
|
+
display: flex;
|
|
3508
|
+
flex-wrap: wrap;
|
|
3509
|
+
gap: 8px;
|
|
3510
|
+
}
|
|
3511
|
+
|
|
1909
3512
|
.publish-diff-head {
|
|
1910
3513
|
display: flex;
|
|
1911
3514
|
flex-wrap: wrap;
|