@@ -4,7 +4,7 @@
|
||||
import { onMount, onDestroy, tick } from "svelte";
|
||||
import { fetchTimeline, connectSSE, formatElapsed, timeAgo } from "./lib/api.js";
|
||||
import { envColors, envLaneColor, envBadgeClasses, statusDotColor } from "./lib/colors.js";
|
||||
import { pipelineSummary, deployStageLabel, waitStageLabel, STATUS_CONFIG } from "./lib/status.js";
|
||||
import { pipelineSummary, deployStageLabel, waitStageLabel, planStageLabel, STATUS_CONFIG } from "./lib/status.js";
|
||||
|
||||
// Props from attributes
|
||||
export let org = "";
|
||||
@@ -96,6 +96,88 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plan stage actions ──────────────────────────────────────────
|
||||
|
||||
let planOutputs = {}; // keyed by "intentId:stageId"
|
||||
let planOutputLoading = new Set();
|
||||
|
||||
async function approvePlanStage(release, stage, reject = false) {
|
||||
const key = `plan:${release.release_intent_id}:${stage.id}`;
|
||||
if (approving.has(key)) return;
|
||||
approving.add(key);
|
||||
approving = approving;
|
||||
approvalError = null;
|
||||
|
||||
try {
|
||||
const action = reject ? "reject" : "approve";
|
||||
const formData = new URLSearchParams();
|
||||
formData.set("csrf_token", csrf);
|
||||
formData.set("release_intent_id", release.release_intent_id);
|
||||
|
||||
const res = await fetch(
|
||||
`/api/orgs/${org}/projects/${release.project_name || project}/plan-stages/${stage.id}/${action}`,
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
await refreshData();
|
||||
} else {
|
||||
const text = await res.text().catch(() => "");
|
||||
let msg;
|
||||
try { msg = JSON.parse(text).error; } catch {}
|
||||
approvalError = msg || `Plan ${action} failed (${res.status})`;
|
||||
setTimeout(() => { approvalError = null; }, 8000);
|
||||
}
|
||||
} catch (err) {
|
||||
approvalError = err.message || "Plan action failed";
|
||||
setTimeout(() => { approvalError = null; }, 8000);
|
||||
} finally {
|
||||
approving.delete(key);
|
||||
approving = approving;
|
||||
}
|
||||
}
|
||||
|
||||
async function viewPlanOutput(release, stage) {
|
||||
const key = `${release.release_intent_id}:${stage.id}`;
|
||||
if (planOutputLoading.has(key)) return;
|
||||
if (planOutputs[key]) {
|
||||
// Toggle off
|
||||
delete planOutputs[key];
|
||||
planOutputs = planOutputs;
|
||||
return;
|
||||
}
|
||||
planOutputLoading.add(key);
|
||||
planOutputLoading = planOutputLoading;
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/orgs/${org}/projects/${release.project_name || project}/plan-stages/${stage.id}/output?release_intent_id=${encodeURIComponent(release.release_intent_id)}`,
|
||||
{ credentials: "same-origin", headers: { "Accept": "application/json" } }
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
planOutputs[key] = data;
|
||||
planOutputs = planOutputs;
|
||||
} else {
|
||||
approvalError = `Failed to load plan output (${res.status})`;
|
||||
setTimeout(() => { approvalError = null; }, 8000);
|
||||
}
|
||||
} catch (err) {
|
||||
approvalError = err.message || "Failed to load plan output";
|
||||
setTimeout(() => { approvalError = null; }, 8000);
|
||||
} finally {
|
||||
planOutputLoading.delete(key);
|
||||
planOutputLoading = planOutputLoading;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data fetching ────────────────────────────────────────────────
|
||||
|
||||
// Debounce re-fetches: multiple SSE events within 300ms only trigger one fetch
|
||||
@@ -428,6 +510,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize plan stage status: the API returns status="RUNNING" with
|
||||
// approval_status="AWAITINGAPPROVAL" (no underscore, Debug format from Rust).
|
||||
// Map this to a single effective status for template rendering.
|
||||
function effectiveStatus(stage) {
|
||||
if (stage.stage_type === "plan" && stage.approval_status &&
|
||||
(stage.approval_status === "AWAITINGAPPROVAL" || stage.approval_status === "AWAITING_APPROVAL")) {
|
||||
return "AWAITING_APPROVAL";
|
||||
}
|
||||
return stage.status;
|
||||
}
|
||||
|
||||
function isPlanAwaiting(stage) {
|
||||
return stage.stage_type === "plan" && effectiveStatus(stage) === "AWAITING_APPROVAL";
|
||||
}
|
||||
|
||||
$: laneCount = lanes.length;
|
||||
$: gutterWidth = laneCount * (BAR_WIDTH + BAR_GAP) + 8;
|
||||
</script>
|
||||
@@ -559,6 +656,18 @@
|
||||
<span class="w-1.5 h-1.5 rounded-full {dot}"></span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if stage.stage_type === "plan" && isPlanAwaiting(stage) && release.release_intent_id && csrf}
|
||||
{@const planBadge = envBadgeClasses(stage.environment || "")}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-purple-100">
|
||||
{stage.environment} plan
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-purple-400"></span>
|
||||
</span>
|
||||
<button
|
||||
class="text-xs px-2 py-0.5 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
disabled={approving.has(`plan:${release.release_intent_id}:${stage.id}`)}
|
||||
on:click|stopPropagation={() => approvePlanStage(release, stage)}
|
||||
>Approve plan</button>
|
||||
{/if}
|
||||
{#if stage.blocked_by && release.release_intent_id && csrf}
|
||||
{#if isAuthor(release) && isAdmin()}
|
||||
<button
|
||||
@@ -630,15 +739,18 @@
|
||||
{#if release.has_pipeline}
|
||||
<div class="border-t border-gray-100">
|
||||
{#each release.pipeline_stages as stage, i (stage.id || `${stage.stage_type}-${stage.environment}-${i}`)}
|
||||
<div class="px-4 py-2.5 flex items-center gap-3 text-sm {i < release.pipeline_stages.length - 1 ? 'border-b border-gray-50' : ''} {stage.status === 'PENDING' ? 'opacity-50' : ''}">
|
||||
{#if stage.status === "SUCCEEDED"}
|
||||
{@const stageStatus = effectiveStatus(stage)}
|
||||
<div class="px-4 py-2.5 flex items-center gap-3 text-sm {i < release.pipeline_stages.length - 1 ? 'border-b border-gray-50' : ''} {stageStatus === 'PENDING' ? 'opacity-50' : ''}">
|
||||
{#if stageStatus === "SUCCEEDED"}
|
||||
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{:else if stage.status === "RUNNING"}
|
||||
{:else if stageStatus === "RUNNING"}
|
||||
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
|
||||
{:else if stage.status === "QUEUED"}
|
||||
{:else if stageStatus === "QUEUED"}
|
||||
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{:else if stage.status === "FAILED"}
|
||||
{:else if stageStatus === "FAILED"}
|
||||
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||
{:else if stageStatus === "AWAITING_APPROVAL"}
|
||||
<svg class="w-4 h-4 text-purple-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/></svg>
|
||||
{:else}
|
||||
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
|
||||
{/if}
|
||||
@@ -656,9 +768,37 @@
|
||||
<span class="text-sm {stage.status === 'SUCCEEDED' ? 'text-gray-700' : stage.status === 'RUNNING' ? 'text-yellow-700' : 'text-gray-400'}">
|
||||
{waitStageLabel(stage.status)} {stage.duration_seconds}s
|
||||
</span>
|
||||
{:else if stage.stage_type === "plan"}
|
||||
<span class="text-sm {stageStatus === 'AWAITING_APPROVAL' ? 'text-purple-700' : stageStatus === 'SUCCEEDED' ? 'text-gray-700' : stageStatus === 'RUNNING' ? 'text-yellow-700' : stageStatus === 'FAILED' ? 'text-red-700' : 'text-gray-400'}">
|
||||
{planStageLabel(stageStatus)}
|
||||
</span>
|
||||
{@const planBadge = envBadgeClasses(stage.environment || "")}
|
||||
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {planBadge.bg}">
|
||||
{stage.environment}
|
||||
<span class="w-1.5 h-1.5 rounded-full {planBadge.dot}"></span>
|
||||
</span>
|
||||
{#if stageStatus === "AWAITING_APPROVAL" && release.release_intent_id && csrf}
|
||||
<button
|
||||
class="text-xs px-2 py-0.5 rounded-md bg-green-600 text-white hover:bg-green-700 transition-colors disabled:opacity-50"
|
||||
disabled={approving.has(`plan:${release.release_intent_id}:${stage.id}`)}
|
||||
on:click|stopPropagation={() => approvePlanStage(release, stage)}
|
||||
>Approve plan</button>
|
||||
<button
|
||||
class="text-xs px-2 py-0.5 rounded-md bg-red-600 text-white hover:bg-red-700 transition-colors disabled:opacity-50"
|
||||
disabled={approving.has(`plan:${release.release_intent_id}:${stage.id}`)}
|
||||
on:click|stopPropagation={() => { if (confirm('Reject this plan?')) approvePlanStage(release, stage, true); }}
|
||||
>Reject</button>
|
||||
{/if}
|
||||
{#if (stageStatus === "AWAITING_APPROVAL" || stageStatus === "SUCCEEDED" || stageStatus === "FAILED") && release.release_intent_id}
|
||||
<button
|
||||
class="text-xs px-2 py-0.5 rounded-md border border-gray-300 text-gray-600 hover:bg-gray-50 transition-colors disabled:opacity-50"
|
||||
disabled={planOutputLoading.has(`${release.release_intent_id}:${stage.id}`)}
|
||||
on:click|stopPropagation={() => viewPlanOutput(release, stage)}
|
||||
>{planOutputs[`${release.release_intent_id}:${stage.id}`] ? "Hide plan" : "View plan"}</button>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if stage.started_at && (stage.status === "RUNNING" || stage.status === "QUEUED" || stage.completed_at)}
|
||||
{#if stage.started_at && (stageStatus === "RUNNING" || stageStatus === "QUEUED" || stageStatus === "AWAITING_APPROVAL" || stage.completed_at)}
|
||||
<span class="text-xs text-gray-400 tabular-nums">{elapsedStr(stage.started_at, stage.completed_at, stage.status)}</span>
|
||||
{/if}
|
||||
|
||||
@@ -667,6 +807,28 @@
|
||||
pipeline
|
||||
</span>
|
||||
</div>
|
||||
{#if stage.stage_type === "plan" && planOutputs[`${release.release_intent_id}:${stage.id}`]}
|
||||
{@const planData = planOutputs[`${release.release_intent_id}:${stage.id}`]}
|
||||
<div class="px-4 py-3 bg-gray-50 border-t border-gray-100 space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-medium text-gray-500">Plan output</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700">{planData.status}</span>
|
||||
</div>
|
||||
{#if planData.outputs && planData.outputs.length > 0}
|
||||
{#each planData.outputs as destOutput (destOutput.destination_id)}
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-xs font-medium text-gray-600">{destOutput.destination_name}</span>
|
||||
<span class="text-xs text-gray-400">{destOutput.status}</span>
|
||||
</div>
|
||||
<pre class="text-xs font-mono text-gray-700 whitespace-pre-wrap bg-white border border-gray-200 rounded p-3 max-h-48 overflow-auto">{destOutput.plan_output || "(no output)"}</pre>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<pre class="text-xs font-mono text-gray-700 whitespace-pre-wrap bg-white border border-gray-200 rounded p-3 max-h-64 overflow-auto">{planData.plan_output || "(no output)"}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -25,9 +25,11 @@ export function pipelineSummary(stages) {
|
||||
}
|
||||
|
||||
let anyApprovalBlocked = stages.some(s => s.blocked_by);
|
||||
let anyPlanAwaiting = stages.some(s => s.stage_type === "plan" && (s.status === "AWAITING_APPROVAL" || s.approval_status === "AWAITINGAPPROVAL" || s.approval_status === "AWAITING_APPROVAL"));
|
||||
|
||||
if (allDone) return { label: "Pipeline complete", color: "text-gray-600", icon: "check-circle", iconColor: "text-green-500", done, total };
|
||||
if (anyFailed) return { label: "Pipeline failed", color: "text-red-600", icon: "x-circle", iconColor: "text-red-500", done, total };
|
||||
if (anyPlanAwaiting) return { label: "Awaiting plan approval", color: "text-purple-700", icon: "shield", iconColor: "text-purple-500", done, total };
|
||||
if (anyApprovalBlocked) return { label: "Awaiting approval", color: "text-emerald-700", icon: "shield", iconColor: "text-emerald-500", done, total };
|
||||
if (anyWaiting) return { label: "Waiting for time window", color: "text-yellow-700", icon: "clock", iconColor: "text-yellow-500", done, total };
|
||||
if (anyRunning) return { label: "Deploying to", color: "text-yellow-700", icon: "pulse", iconColor: "text-yellow-500", done, total };
|
||||
@@ -64,3 +66,14 @@ export function deployStageLabel(status) {
|
||||
default: return "Deploy to";
|
||||
}
|
||||
}
|
||||
|
||||
export function planStageLabel(status) {
|
||||
switch (status) {
|
||||
case "SUCCEEDED": return "Plan approved";
|
||||
case "RUNNING": return "Planning";
|
||||
case "AWAITING_APPROVAL": return "Awaiting plan approval";
|
||||
case "FAILED": return "Plan failed";
|
||||
case "CANCELLED": return "Plan cancelled";
|
||||
default: return "Plan";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user