feat: add plan step

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-15 22:38:18 +01:00
parent 7eb6ae7cbb
commit 04e452ecc3
71 changed files with 1059 additions and 319 deletions

View File

@@ -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}

View File

@@ -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";
}
}