diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index df8f23d..cd92605 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -569,8 +569,10 @@ export function App() { prod={prodDriverView?.lane_summary ?? null} testing={testingDriverView?.lane_summary ?? null} actions={actions} + environmentActions={activeEnvironment?.available_actions ?? []} product={selectedDriver?.product ?? selected.driverId} context={selected.prodContext} + environment={activeEnvironment?.environment ?? selectedEnvironment} decision={promotionDecision} loading={loading} onAction={setReviewAction} @@ -1324,8 +1326,10 @@ function StateFixtureGallery({ prod={missingBackupProd} testing={readyTesting} actions={FIXTURE_GENERIC_WEB_ACTIONS} + environmentActions={configEnvironments[1].available_actions} product="sellyouroutboard" context="sellyouroutboard-testing" + environment="prod" decision={buildPromotionDecision(missingBackupProd, readyTesting, { requireProdBackup: false, })} diff --git a/frontend/src/PromotionBridge.tsx b/frontend/src/PromotionBridge.tsx index f5605d4..ddf9e7d 100644 --- a/frontend/src/PromotionBridge.tsx +++ b/frontend/src/PromotionBridge.tsx @@ -23,6 +23,7 @@ import type { GenericWebPromotionWorkflowPayload, GenericWebPromotionWorkflowRequest, LaneSummary, + ProductActionAvailability, Status, } from "./types"; @@ -54,8 +55,10 @@ export function PromotionBridge({ prod, testing, actions, + environmentActions = [], product, context, + environment = "prod", decision, loading, onAction, @@ -65,8 +68,10 @@ export function PromotionBridge({ prod: LaneSummary | null; testing: LaneSummary | null; actions: DriverActionDescriptor[]; + environmentActions?: ProductActionAvailability[]; product: string; context: string; + environment?: string; decision: PromotionDecision; loading: boolean; onAction: (action: DriverActionDescriptor) => void; @@ -82,6 +87,12 @@ export function PromotionBridge({ (action) => action.route_path === "/v1/drivers/generic-web/prod-promotion-workflow", ); + const productPromotionAction = environmentActions.find( + (action) => action.action_id === "prod_promotion", + ); + const productWorkflowAction = environmentActions.find( + (action) => action.action_id === "prod_promotion_workflow", + ); const [dryRunResult, setDryRunResult] = useState(null); const [workflowResult, setWorkflowResult] = @@ -98,6 +109,9 @@ export function PromotionBridge({ const testingSourceRef = sourceRefFromLane(testing); const supportsGenericWebPromotion = primaryAction?.route_path === "/v1/drivers/generic-web/prod-promotion"; + const productAllowsWorkflow = productWorkflowAction?.enabled ?? Boolean(workflowAction); + const promotionBlockers = actionDisabledReasons(productPromotionAction); + const workflowBlockers = actionDisabledReasons(productWorkflowAction); const canDryRun = Boolean( supportsGenericWebPromotion && decision.verdict === "ready" && @@ -206,8 +220,8 @@ export function PromotionBridge({ return (
} /> {loading ? ( @@ -234,6 +248,12 @@ export function PromotionBridge({ {decision.prodArtifact || "unknown prod"} +
{decision.gates.map((gate) => (
@@ -265,6 +285,17 @@ export function PromotionBridge({ )} Dry run promotion + {!canDryRun ? ( + + ) : null} {dryRunResult ? (
-
) : null} + {dryRunResult && !canDispatchWorkflow ? ( + + ) : null} ) : (
+ ); +} + +function ActionAvailabilityRow({ + label, + action, + blockers, +}: { + label: string; + action?: ProductActionAvailability; + blockers: string[]; +}) { + if (!action) { + return ( +
+ {label} + not advertised +
+ ); + } + return ( +
+ {label} + {action.enabled ? "available" : blockers.join("; ") || "blocked"} +
+ ); +} + +function ActionBlockerList({ reasons }: { reasons: string[] }) { + if (!reasons.length) { + return null; + } + return ( +
+ {reasons.map((reason) => ( + {reason} + ))} +
+ ); +} + +function actionDisabledReasons(action?: ProductActionAvailability): string[] { + if (!action || action.enabled) { + return []; + } + return action.disabled_reasons.length ? action.disabled_reasons : ["Action is disabled."]; +} + +function dryRunDisabledReasons({ + decision, + testingArtifact, + testingSourceRef, + product, + context, +}: { + decision: PromotionDecision; + testingArtifact: string; + testingSourceRef: string; + product: string; + context: string; +}): string[] { + const reasons: string[] = []; + if (decision.verdict !== "ready") { + reasons.push(decision.blockingEvidence || "Promotion evidence is not ready."); + } + if (!testingArtifact) { + reasons.push("Testing artifact evidence is missing."); + } + if (!testingSourceRef) { + reasons.push("Testing source ref evidence is missing."); + } + if (!product.trim()) { + reasons.push("Product key is missing."); + } + if (!context.trim()) { + reasons.push("Prod context is missing."); + } + return uniqueStrings(reasons); +} + +function workflowDisabledReasons({ + product, + context, +}: { + product: string; + context: string; +}): string[] { + const reasons: string[] = []; + if (!product.trim()) { + reasons.push("Product key is missing."); + } + if (!context.trim()) { + reasons.push("Prod context is missing."); + } + return uniqueStrings(reasons); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.map((value) => value.trim()).filter(Boolean))]; +} + export function buildPromotionDecision( prod: LaneSummary | null, testing: LaneSummary | null, diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 5d33b25..15d95e8 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1123,6 +1123,35 @@ p { padding: 6px 0; } +.bridge-action-availability { + display: grid; + gap: 1px; + border-bottom: 1px solid var(--hair-soft); + background: var(--hair-soft); +} + +.bridge-action-availability-row { + display: grid; + grid-template-columns: minmax(130px, 0.46fr) minmax(0, 1fr); + gap: 10px; + min-width: 0; + background: color-mix(in oklab, var(--panel) 92%, var(--bg-1)); + padding: 9px 14px; +} + +.bridge-action-availability-row strong { + color: var(--fg-strong); + font-size: 12px; +} + +.bridge-action-availability-row span { + color: var(--status-fail); +} + +.bridge-action-availability-row[data-enabled="true"] span { + color: var(--status-ok); +} + .gate-row, .preview-row, .secret-row, @@ -1173,10 +1202,24 @@ p { .workflow-action-row { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: minmax(0, 1fr); gap: 10px; } +.bridge-action-blockers { + display: grid; + gap: 5px; + border: 1px solid var(--hair-soft); + border-radius: 6px; + background: color-mix(in oklab, var(--status-fail) 8%, var(--bg-2)); + padding: 9px 10px; +} + +.bridge-action-blockers span { + color: var(--fg-muted); + font-size: 12px; +} + .bridge-inline-alert, .bridge-dry-run-result, .bridge-workflow-result { @@ -2044,6 +2087,10 @@ p { grid-template-columns: minmax(0, 1fr); } + .fixture-grid { + grid-template-columns: minmax(0, 1fr); + } + .environment-detail-strip { margin: 0 12px 12px; }