@@ -1 +1,70 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* ── Dark mode (system preference) ──────────────────────────────────────── */
|
||||
/* Remap Tailwind's color variables so all existing utilities adapt automatically. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root, :host {
|
||||
/* Neutrals — invert the gray scale */
|
||||
--color-white: oklch(14.5% 0.015 260);
|
||||
--color-black: oklch(98% 0.002 248);
|
||||
--color-gray-50: oklch(17.5% 0.02 260);
|
||||
--color-gray-100: oklch(21% 0.024 265);
|
||||
--color-gray-200: oklch(27.8% 0.025 257);
|
||||
--color-gray-300: oklch(37.3% 0.025 260);
|
||||
--color-gray-400: oklch(55.1% 0.02 264);
|
||||
--color-gray-500: oklch(60% 0.02 264);
|
||||
--color-gray-600: oklch(70.7% 0.017 261);
|
||||
--color-gray-700: oklch(80% 0.012 258);
|
||||
--color-gray-800: oklch(87.2% 0.008 258);
|
||||
--color-gray-900: oklch(93% 0.005 265);
|
||||
--color-gray-950: oklch(96.7% 0.003 265);
|
||||
|
||||
/* Green — darken light tints, lighten dark shades */
|
||||
--color-green-50: oklch(20% 0.04 155);
|
||||
--color-green-100: oklch(25% 0.06 155);
|
||||
--color-green-200: oklch(30% 0.08 155);
|
||||
--color-green-300: oklch(42% 0.12 154);
|
||||
--color-green-700: oklch(75% 0.15 150);
|
||||
--color-green-800: oklch(80% 0.12 150);
|
||||
|
||||
/* Red */
|
||||
--color-red-50: oklch(22% 0.04 17);
|
||||
--color-red-200: oklch(32% 0.06 18);
|
||||
--color-red-600: oklch(65% 0.2 27);
|
||||
--color-red-700: oklch(72% 0.18 27);
|
||||
--color-red-800: oklch(77% 0.15 27);
|
||||
|
||||
/* Blue */
|
||||
--color-blue-100: oklch(22% 0.04 255);
|
||||
--color-blue-600: oklch(62% 0.2 263);
|
||||
--color-blue-700: oklch(72% 0.17 264);
|
||||
--color-blue-800: oklch(77% 0.15 265);
|
||||
|
||||
/* Orange */
|
||||
--color-orange-100: oklch(25% 0.05 75);
|
||||
--color-orange-800: oklch(78% 0.13 37);
|
||||
|
||||
/* Yellow */
|
||||
--color-yellow-100: oklch(25% 0.06 103);
|
||||
--color-yellow-700: oklch(72% 0.12 66);
|
||||
--color-yellow-800: oklch(77% 0.1 62);
|
||||
|
||||
/* Violet */
|
||||
--color-violet-100: oklch(22% 0.04 295);
|
||||
--color-violet-200: oklch(28% 0.06 294);
|
||||
--color-violet-400: oklch(45% 0.14 293);
|
||||
--color-violet-600: oklch(60% 0.2 293);
|
||||
--color-violet-800: oklch(75% 0.18 293);
|
||||
|
||||
/* Purple */
|
||||
--color-purple-100: oklch(22% 0.04 307);
|
||||
--color-purple-800: oklch(75% 0.17 304);
|
||||
|
||||
/* Pink */
|
||||
--color-pink-100: oklch(22% 0.04 342);
|
||||
--color-pink-800: oklch(75% 0.15 4);
|
||||
|
||||
/* Amber */
|
||||
--color-amber-400: oklch(80% 0.17 84);
|
||||
}
|
||||
}
|
||||
|
||||
1332
static/css/style.css
1332
static/css/style.css
File diff suppressed because one or more lines are too long
19
static/js/components/forage-components.js
Normal file
19
static/js/components/forage-components.js
Normal file
File diff suppressed because one or more lines are too long
38
static/js/details-persist.js
Normal file
38
static/js/details-persist.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Persists the open/closed state of <details> elements inside [data-release]
|
||||
* cards across page reloads using sessionStorage.
|
||||
*
|
||||
* Key format: `details:<page-path>:<release-slug>`
|
||||
*/
|
||||
(function () {
|
||||
const prefix = "details:" + location.pathname + ":";
|
||||
|
||||
// Restore open state on load
|
||||
document.querySelectorAll("[data-release][data-release-slug]").forEach((card) => {
|
||||
const slug = card.dataset.releaseSlug;
|
||||
const details = card.querySelector("details");
|
||||
if (!details || !slug) return;
|
||||
|
||||
if (sessionStorage.getItem(prefix + slug) === "1") {
|
||||
details.open = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for toggle events (works for both open and close)
|
||||
document.addEventListener("toggle", (e) => {
|
||||
const details = e.target;
|
||||
if (details.tagName !== "DETAILS") return;
|
||||
|
||||
const card = details.closest("[data-release][data-release-slug]");
|
||||
if (!card) return;
|
||||
|
||||
const slug = card.dataset.releaseSlug;
|
||||
if (!slug) return;
|
||||
|
||||
if (details.open) {
|
||||
sessionStorage.setItem(prefix + slug, "1");
|
||||
} else {
|
||||
sessionStorage.removeItem(prefix + slug);
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
701
static/js/live-events.js
Normal file
701
static/js/live-events.js
Normal file
@@ -0,0 +1,701 @@
|
||||
/**
|
||||
* Live event updates via SSE.
|
||||
*
|
||||
* Connects to the project events endpoint and updates the deployment UI
|
||||
* in real-time when destination statuses change.
|
||||
*
|
||||
* Usage: <script src="/static/js/live-events.js"
|
||||
* data-org="rawpotion" data-project="my-app"></script>
|
||||
*/
|
||||
(function () {
|
||||
const script = document.currentScript;
|
||||
const org = script?.dataset.org;
|
||||
const project = script?.dataset.project;
|
||||
if (!org || !project) return;
|
||||
|
||||
const url = `/orgs/${org}/projects/${project}/events`;
|
||||
let lastSequence = 0;
|
||||
let retryDelay = 1000;
|
||||
|
||||
function connect() {
|
||||
const es = new EventSource(url);
|
||||
|
||||
es.addEventListener("open", () => {
|
||||
retryDelay = 1000;
|
||||
});
|
||||
|
||||
// destination status_changed events update inline badges
|
||||
es.addEventListener("destination", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
lastSequence = Math.max(lastSequence, data.sequence || 0);
|
||||
handleDestinationEvent(data);
|
||||
} catch (err) {
|
||||
console.warn("[live-events] bad destination event:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// release events
|
||||
es.addEventListener("release", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
lastSequence = Math.max(lastSequence, data.sequence || 0);
|
||||
if (data.action === "created") {
|
||||
window.location.reload();
|
||||
} else if (
|
||||
data.action === "status_changed" ||
|
||||
data.action === "updated"
|
||||
) {
|
||||
handleReleaseEvent(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[live-events] bad release event:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// artifact events -> reload to show new artifacts
|
||||
es.addEventListener("artifact", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.action === "created" || data.action === "updated") {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[live-events] bad artifact event:", err);
|
||||
}
|
||||
});
|
||||
|
||||
// pipeline events (pipeline run progress)
|
||||
es.addEventListener("pipeline", (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
lastSequence = Math.max(lastSequence, data.sequence || 0);
|
||||
handlePipelineEvent(data);
|
||||
} catch (err) {
|
||||
console.warn("[live-events] bad pipeline event:", err);
|
||||
}
|
||||
});
|
||||
|
||||
es.addEventListener("error", () => {
|
||||
es.close();
|
||||
// Reconnect with exponential backoff
|
||||
setTimeout(connect, retryDelay);
|
||||
retryDelay = Math.min(retryDelay * 2, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Status update helpers ──────────────────────────────────────────
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
SUCCEEDED: {
|
||||
icon: "check-circle",
|
||||
iconColor: "text-green-500",
|
||||
label: "Deployed",
|
||||
labelColor: "text-green-600",
|
||||
summaryIcon: "check-circle",
|
||||
summaryColor: "text-green-500",
|
||||
summaryLabel: "Deployed to",
|
||||
summaryLabelColor: "text-gray-600",
|
||||
},
|
||||
RUNNING: {
|
||||
icon: "pulse",
|
||||
iconColor: "text-yellow-500",
|
||||
label: "Deploying",
|
||||
labelColor: "text-yellow-600",
|
||||
summaryIcon: "pulse",
|
||||
summaryColor: "text-yellow-500",
|
||||
summaryLabel: "Deploying to",
|
||||
summaryLabelColor: "text-yellow-700",
|
||||
},
|
||||
ASSIGNED: {
|
||||
icon: "pulse",
|
||||
iconColor: "text-yellow-500",
|
||||
label: "Assigned",
|
||||
labelColor: "text-yellow-600",
|
||||
summaryIcon: "pulse",
|
||||
summaryColor: "text-yellow-500",
|
||||
summaryLabel: "Deploying to",
|
||||
summaryLabelColor: "text-yellow-700",
|
||||
},
|
||||
QUEUED: {
|
||||
icon: "clock",
|
||||
iconColor: "text-blue-400",
|
||||
label: "Queued",
|
||||
labelColor: "text-blue-600",
|
||||
summaryIcon: "clock",
|
||||
summaryColor: "text-blue-400",
|
||||
summaryLabel: "Queued for",
|
||||
summaryLabelColor: "text-blue-600",
|
||||
},
|
||||
FAILED: {
|
||||
icon: "x-circle",
|
||||
iconColor: "text-red-500",
|
||||
label: "Failed",
|
||||
labelColor: "text-red-600",
|
||||
summaryIcon: "x-circle",
|
||||
summaryColor: "text-red-500",
|
||||
summaryLabel: "Failed on",
|
||||
summaryLabelColor: "text-red-600",
|
||||
},
|
||||
TIMED_OUT: {
|
||||
icon: "clock",
|
||||
iconColor: "text-orange-500",
|
||||
label: "Timed out",
|
||||
labelColor: "text-orange-600",
|
||||
summaryIcon: "clock",
|
||||
summaryColor: "text-orange-500",
|
||||
summaryLabel: "Timed out on",
|
||||
summaryLabelColor: "text-orange-600",
|
||||
},
|
||||
CANCELLED: {
|
||||
icon: "ban",
|
||||
iconColor: "text-gray-400",
|
||||
label: "Cancelled",
|
||||
labelColor: "text-gray-500",
|
||||
summaryIcon: "ban",
|
||||
summaryColor: "text-gray-400",
|
||||
summaryLabel: "Cancelled",
|
||||
summaryLabelColor: "text-gray-500",
|
||||
},
|
||||
};
|
||||
|
||||
function makeStatusIcon(type, colorClass) {
|
||||
if (type === "pulse") {
|
||||
const span = document.createElement("span");
|
||||
span.className = "w-4 h-4 shrink-0 flex items-center justify-center";
|
||||
span.innerHTML =
|
||||
'<span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span>';
|
||||
return span;
|
||||
}
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
svg.setAttribute("class", `w-4 h-4 ${colorClass} shrink-0`);
|
||||
svg.setAttribute("fill", "none");
|
||||
svg.setAttribute("stroke", "currentColor");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
const path = document.createElementNS(
|
||||
"http://www.w3.org/2000/svg",
|
||||
"path"
|
||||
);
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
path.setAttribute("stroke-width", "2");
|
||||
const paths = {
|
||||
"check-circle": "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
"x-circle":
|
||||
"M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
clock: "M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z",
|
||||
ban: "M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636",
|
||||
};
|
||||
path.setAttribute("d", paths[type] || paths["check-circle"]);
|
||||
svg.appendChild(path);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function handleDestinationEvent(data) {
|
||||
if (data.action !== "status_changed") return;
|
||||
const status = data.metadata?.status;
|
||||
const destName = data.metadata?.destination_name || data.resource_id;
|
||||
const env = data.metadata?.environment;
|
||||
if (!status || !destName) return;
|
||||
|
||||
const config = STATUS_CONFIG[status];
|
||||
if (!config) return;
|
||||
|
||||
// Find all destination rows that match
|
||||
document
|
||||
.querySelectorAll("[data-release] details .px-4.py-2")
|
||||
.forEach((row) => {
|
||||
const nameSpan = row.querySelector(".text-gray-400.text-xs");
|
||||
if (!nameSpan || nameSpan.textContent.trim() !== destName) return;
|
||||
|
||||
// Update the status icon (first child element)
|
||||
const oldIcon = row.firstElementChild;
|
||||
if (oldIcon) {
|
||||
const newIcon = makeStatusIcon(config.icon, config.iconColor);
|
||||
row.replaceChild(newIcon, oldIcon);
|
||||
}
|
||||
|
||||
// Update the status label text
|
||||
const labels = row.querySelectorAll("span[class*='text-xs text-']");
|
||||
labels.forEach((label) => {
|
||||
const text = label.textContent.trim();
|
||||
if (
|
||||
[
|
||||
"Deployed",
|
||||
"Deploying",
|
||||
"Assigned",
|
||||
"Queued",
|
||||
"Failed",
|
||||
"Timed out",
|
||||
"Cancelled",
|
||||
].some((s) => text.startsWith(s))
|
||||
) {
|
||||
label.textContent = config.label;
|
||||
// Reset classes
|
||||
label.className = `text-xs ${config.labelColor}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update pipeline stage rows that match this environment
|
||||
if (env) {
|
||||
updatePipelineStages(env, status, config);
|
||||
}
|
||||
|
||||
// Also update the summary line for the parent release card
|
||||
updateReleaseSummary(data);
|
||||
}
|
||||
|
||||
function updatePipelineStages(env, status, config) {
|
||||
document
|
||||
.querySelectorAll(
|
||||
`[data-pipeline-stage][data-stage-type="deploy"][data-stage-env="${env}"]`
|
||||
)
|
||||
.forEach((row) => {
|
||||
// Update data attributes
|
||||
row.dataset.stageStatus = status;
|
||||
|
||||
// Set started_at if transitioning to an active state and not already set
|
||||
if (
|
||||
(status === "RUNNING" || status === "QUEUED") &&
|
||||
!row.dataset.startedAt
|
||||
) {
|
||||
row.dataset.startedAt = new Date().toISOString();
|
||||
}
|
||||
// Set completed_at when reaching a terminal state
|
||||
if (
|
||||
["SUCCEEDED", "FAILED", "TIMED_OUT", "CANCELLED"].includes(status) &&
|
||||
!row.dataset.completedAt
|
||||
) {
|
||||
row.dataset.completedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
// Ensure elapsed span exists for active stages
|
||||
if (
|
||||
(status === "RUNNING" || status === "QUEUED") &&
|
||||
!row.querySelector("[data-elapsed]")
|
||||
) {
|
||||
const pipelineLabel = row.querySelector("span.ml-auto");
|
||||
if (pipelineLabel) {
|
||||
const el = document.createElement("span");
|
||||
el.className = "text-xs text-gray-400 tabular-nums";
|
||||
el.dataset.elapsed = "";
|
||||
pipelineLabel.before(el);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle opacity for pending vs active
|
||||
if (status === "PENDING") {
|
||||
row.classList.add("opacity-50");
|
||||
} else {
|
||||
row.classList.remove("opacity-50");
|
||||
}
|
||||
|
||||
// Replace status icon (first child element)
|
||||
const oldIcon = row.firstElementChild;
|
||||
if (oldIcon) {
|
||||
const newIcon = makeStatusIcon(config.icon, config.iconColor);
|
||||
row.replaceChild(newIcon, oldIcon);
|
||||
}
|
||||
|
||||
// Update the status text span (e.g. "Deploying to" -> "Deployed to")
|
||||
const textSpan = row.querySelector("span.text-sm");
|
||||
if (textSpan) {
|
||||
const labels = {
|
||||
SUCCEEDED: "Deployed to",
|
||||
RUNNING: "Deploying to",
|
||||
QUEUED: "Queued for",
|
||||
FAILED: "Failed on",
|
||||
TIMED_OUT: "Timed out on",
|
||||
CANCELLED: "Cancelled",
|
||||
};
|
||||
if (labels[status]) textSpan.textContent = labels[status];
|
||||
// Update text color
|
||||
const colors = {
|
||||
SUCCEEDED: "text-gray-700",
|
||||
RUNNING: "text-yellow-700",
|
||||
QUEUED: "text-blue-600",
|
||||
FAILED: "text-red-700",
|
||||
TIMED_OUT: "text-orange-600",
|
||||
CANCELLED: "text-gray-500",
|
||||
};
|
||||
textSpan.className = `text-sm ${colors[status] || "text-gray-600"}`;
|
||||
}
|
||||
|
||||
// Update the env badge dot color
|
||||
const badge = row.querySelector(
|
||||
"span.inline-flex span.rounded-full:last-child"
|
||||
);
|
||||
if (badge) {
|
||||
const dotColors = {
|
||||
SUCCEEDED: "bg-green-500",
|
||||
RUNNING: "bg-yellow-500",
|
||||
FAILED: "bg-red-500",
|
||||
};
|
||||
if (dotColors[status]) {
|
||||
badge.className = `w-1.5 h-1.5 rounded-full ${dotColors[status]}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updateReleaseSummary(_data) {
|
||||
// Re-compute summaries by scanning pipeline stage rows or destination rows.
|
||||
document.querySelectorAll("[data-release]").forEach((card) => {
|
||||
const summary = card.querySelector("details > summary");
|
||||
if (!summary) return;
|
||||
|
||||
const pipelineStages = card.querySelectorAll("[data-pipeline-stage]");
|
||||
const hasPipeline = pipelineStages.length > 0;
|
||||
|
||||
if (hasPipeline) {
|
||||
updatePipelineSummary(summary, pipelineStages);
|
||||
} else {
|
||||
updateDestinationSummary(summary, card);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function updatePipelineSummary(summary, stages) {
|
||||
let allDone = true;
|
||||
let anyFailed = false;
|
||||
let anyRunning = false;
|
||||
let anyWaiting = false;
|
||||
let done = 0;
|
||||
const total = stages.length;
|
||||
const envBadges = [];
|
||||
|
||||
stages.forEach((row) => {
|
||||
const status = row.dataset.stageStatus || "PENDING";
|
||||
const stageType = row.dataset.stageType;
|
||||
const env = row.dataset.stageEnv;
|
||||
|
||||
if (status === "SUCCEEDED") done++;
|
||||
if (status !== "SUCCEEDED") allDone = false;
|
||||
if (status === "FAILED") anyFailed = true;
|
||||
if (status === "RUNNING") anyRunning = true;
|
||||
if (stageType === "wait" && status === "RUNNING") anyWaiting = true;
|
||||
|
||||
// Collect env badges for non-PENDING deploy stages
|
||||
if (stageType === "deploy" && status !== "PENDING" && env) {
|
||||
envBadges.push({ env, status });
|
||||
}
|
||||
});
|
||||
|
||||
const chevron = summary.querySelector("svg:last-child");
|
||||
summary.innerHTML = "";
|
||||
|
||||
// Pipeline gear icon
|
||||
const gear = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
gear.setAttribute("class", "w-3.5 h-3.5 text-purple-400 shrink-0");
|
||||
gear.setAttribute("fill", "none");
|
||||
gear.setAttribute("stroke", "currentColor");
|
||||
gear.setAttribute("viewBox", "0 0 24 24");
|
||||
gear.innerHTML =
|
||||
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>';
|
||||
summary.appendChild(gear);
|
||||
|
||||
// Status icon + label
|
||||
let statusIcon, statusLabel, statusLabelColor;
|
||||
if (allDone) {
|
||||
statusIcon = makeStatusIcon("check-circle", "text-green-500");
|
||||
statusLabel = "Pipeline complete";
|
||||
statusLabelColor = "text-gray-600";
|
||||
} else if (anyFailed) {
|
||||
statusIcon = makeStatusIcon("x-circle", "text-red-500");
|
||||
statusLabel = "Pipeline failed";
|
||||
statusLabelColor = "text-red-600";
|
||||
} else if (anyWaiting) {
|
||||
statusIcon = makeStatusIcon("clock", "text-yellow-500");
|
||||
statusLabel = "Waiting for time window";
|
||||
statusLabelColor = "text-yellow-700";
|
||||
} else if (anyRunning) {
|
||||
statusIcon = makeStatusIcon("pulse", "text-yellow-500");
|
||||
statusLabel = "Deploying to";
|
||||
statusLabelColor = "text-yellow-700";
|
||||
} else {
|
||||
statusIcon = makeStatusIcon("clock", "text-gray-300");
|
||||
statusLabel = "Pipeline pending";
|
||||
statusLabelColor = "text-gray-400";
|
||||
}
|
||||
|
||||
summary.appendChild(statusIcon);
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.className = `${statusLabelColor} text-sm`;
|
||||
labelSpan.textContent = statusLabel;
|
||||
summary.appendChild(labelSpan);
|
||||
|
||||
// Environment badges
|
||||
for (const { env, status } of envBadges) {
|
||||
summary.appendChild(makeEnvBadge(env, status));
|
||||
}
|
||||
|
||||
// Progress counter
|
||||
const progress = document.createElement("span");
|
||||
progress.className = "text-xs text-gray-400";
|
||||
progress.textContent = `${done}/${total}`;
|
||||
summary.appendChild(progress);
|
||||
|
||||
if (chevron) summary.appendChild(chevron);
|
||||
}
|
||||
|
||||
function updateDestinationSummary(summary, card) {
|
||||
// Collect current statuses from destination rows
|
||||
const rows = card.querySelectorAll("details .px-4.py-2");
|
||||
const envStatuses = new Map();
|
||||
rows.forEach((row) => {
|
||||
const envBadge = row.querySelector("[class*='rounded-full']");
|
||||
const envName =
|
||||
envBadge?.closest("span[class*='px-2']")?.textContent?.trim() || "";
|
||||
const labels = row.querySelectorAll("span[class*='text-xs text-']");
|
||||
let status = "";
|
||||
labels.forEach((l) => {
|
||||
const t = l.textContent.trim();
|
||||
if (t === "Deployed") status = "SUCCEEDED";
|
||||
else if (t === "Deploying" || t === "Assigned") status = "RUNNING";
|
||||
else if (t.startsWith("Queued")) status = "QUEUED";
|
||||
else if (t === "Failed") status = "FAILED";
|
||||
else if (t === "Timed out") status = "TIMED_OUT";
|
||||
else if (t === "Cancelled") status = "CANCELLED";
|
||||
});
|
||||
if (envName && status) envStatuses.set(envName, status);
|
||||
});
|
||||
|
||||
if (envStatuses.size === 0) return;
|
||||
|
||||
const groups = new Map();
|
||||
for (const [env, st] of envStatuses) {
|
||||
if (!groups.has(st)) groups.set(st, []);
|
||||
groups.get(st).push(env);
|
||||
}
|
||||
|
||||
const chevron = summary.querySelector("svg:last-child");
|
||||
summary.innerHTML = "";
|
||||
|
||||
for (const [status, envs] of groups) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
if (!cfg) continue;
|
||||
|
||||
summary.appendChild(makeStatusIcon(cfg.summaryIcon, cfg.summaryColor));
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = `${cfg.summaryLabelColor} text-sm`;
|
||||
label.textContent = cfg.summaryLabel;
|
||||
summary.appendChild(label);
|
||||
|
||||
for (const env of envs) {
|
||||
summary.appendChild(makeEnvBadge(env, status));
|
||||
}
|
||||
}
|
||||
|
||||
if (chevron) summary.appendChild(chevron);
|
||||
}
|
||||
|
||||
function makeEnvBadge(env, status) {
|
||||
const badge = document.createElement("span");
|
||||
let bgClass = "bg-gray-100 text-gray-700";
|
||||
let dotClass = "bg-gray-400";
|
||||
if (env.includes("prod") && !env.includes("preprod")) {
|
||||
bgClass = "bg-pink-100 text-pink-800";
|
||||
dotClass = "bg-pink-500";
|
||||
} else if (env.includes("preprod") || env.includes("pre-prod")) {
|
||||
bgClass = "bg-orange-100 text-orange-800";
|
||||
dotClass = "bg-orange-500";
|
||||
} else if (env.includes("stag")) {
|
||||
bgClass = "bg-yellow-100 text-yellow-800";
|
||||
dotClass = "bg-yellow-500";
|
||||
} else if (env.includes("dev")) {
|
||||
bgClass = "bg-violet-100 text-violet-800";
|
||||
dotClass = "bg-violet-500";
|
||||
}
|
||||
// Override dot color based on stage status
|
||||
const statusDots = {
|
||||
SUCCEEDED: "bg-green-500",
|
||||
RUNNING: "bg-yellow-500",
|
||||
FAILED: "bg-red-500",
|
||||
};
|
||||
if (statusDots[status]) dotClass = statusDots[status];
|
||||
|
||||
badge.className = `inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${bgClass}`;
|
||||
badge.innerHTML = `${env} <span class="w-1.5 h-1.5 rounded-full ${dotClass}"></span>`;
|
||||
return badge;
|
||||
}
|
||||
|
||||
// ── Release event handler ─────────────────────────────────────────
|
||||
|
||||
function handleReleaseEvent(data) {
|
||||
// Release status_changed or updated: metadata may carry per-destination
|
||||
// updates, or a high-level status change. Treat it as a destination update
|
||||
// when we have environment + status metadata; otherwise reload for safety.
|
||||
const status = data.metadata?.status;
|
||||
const env = data.metadata?.environment;
|
||||
|
||||
if (status && env) {
|
||||
// We have enough info to do an inline update
|
||||
const config = STATUS_CONFIG[status];
|
||||
if (config) {
|
||||
updatePipelineStages(env, status, config);
|
||||
updateReleaseSummary(data);
|
||||
}
|
||||
} else {
|
||||
// Generic release change — reload to pick up new state
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pipeline event handler ──────────────────────────────────────────
|
||||
|
||||
function handlePipelineEvent(data) {
|
||||
// Pipeline events carry stage-level status updates in metadata:
|
||||
// stage_id, stage_type, environment, status, started_at, completed_at, error_message
|
||||
const stageStatus = data.metadata?.status;
|
||||
const stageEnv = data.metadata?.environment;
|
||||
const stageType = data.metadata?.stage_type;
|
||||
const stageId = data.metadata?.stage_id;
|
||||
|
||||
if (!stageStatus) {
|
||||
// Can't do inline update without status — reload
|
||||
if (data.action === "created" || data.action === "updated") {
|
||||
window.location.reload();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const config = STATUS_CONFIG[stageStatus];
|
||||
|
||||
// Update pipeline stage rows by environment (deploy stages)
|
||||
if (stageEnv && config) {
|
||||
updatePipelineStages(stageEnv, stageStatus, config);
|
||||
}
|
||||
|
||||
// Also update by stage_id for wait stages or when env isn't enough
|
||||
if (stageId) {
|
||||
document
|
||||
.querySelectorAll(`[data-pipeline-stage]`)
|
||||
.forEach((row) => {
|
||||
// Match on the stage id attribute if we had one, but we use
|
||||
// stage_type + env. For wait stages, update all wait stages
|
||||
// in the same card context.
|
||||
if (stageType === "wait" && row.dataset.stageType === "wait") {
|
||||
row.dataset.stageStatus = stageStatus;
|
||||
|
||||
if (stageStatus === "RUNNING") {
|
||||
row.classList.remove("opacity-50");
|
||||
if (!row.dataset.startedAt) {
|
||||
row.dataset.startedAt =
|
||||
data.metadata?.started_at || new Date().toISOString();
|
||||
}
|
||||
} else if (stageStatus === "SUCCEEDED") {
|
||||
row.classList.remove("opacity-50");
|
||||
if (!row.dataset.completedAt) {
|
||||
row.dataset.completedAt =
|
||||
data.metadata?.completed_at || new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
// Update icon
|
||||
const iconCfg = STATUS_CONFIG[stageStatus];
|
||||
if (iconCfg) {
|
||||
const oldIcon = row.firstElementChild;
|
||||
if (oldIcon) {
|
||||
const newIcon = makeStatusIcon(
|
||||
iconCfg.icon,
|
||||
iconCfg.iconColor
|
||||
);
|
||||
row.replaceChild(newIcon, oldIcon);
|
||||
}
|
||||
}
|
||||
|
||||
// Update text ("Waiting" -> "Waited")
|
||||
const textSpan = row.querySelector("span.text-sm");
|
||||
if (textSpan) {
|
||||
const dur = textSpan.textContent.match(/\d+s/)?.[0] || "";
|
||||
if (stageStatus === "SUCCEEDED") {
|
||||
textSpan.textContent = `Waited ${dur}`;
|
||||
textSpan.className = "text-sm text-gray-700";
|
||||
} else if (stageStatus === "RUNNING") {
|
||||
textSpan.textContent = `Waiting ${dur}`;
|
||||
textSpan.className = "text-sm text-yellow-700";
|
||||
} else if (stageStatus === "FAILED") {
|
||||
textSpan.textContent = `Wait failed ${dur}`;
|
||||
textSpan.className = "text-sm text-red-700";
|
||||
} else if (stageStatus === "CANCELLED") {
|
||||
textSpan.textContent = `Wait cancelled ${dur}`;
|
||||
textSpan.className = "text-sm text-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
// Remove wait_until span on completion
|
||||
if (["SUCCEEDED", "FAILED", "CANCELLED"].includes(stageStatus)) {
|
||||
const waitUntil = row.querySelector("[data-wait-until]");
|
||||
if (waitUntil) waitUntil.remove();
|
||||
}
|
||||
|
||||
// Ensure elapsed span exists
|
||||
if (
|
||||
(stageStatus === "RUNNING" || stageStatus === "QUEUED") &&
|
||||
!row.querySelector("[data-elapsed]")
|
||||
) {
|
||||
const pipelineLabel = row.querySelector("span.ml-auto");
|
||||
if (pipelineLabel) {
|
||||
const el = document.createElement("span");
|
||||
el.className = "text-xs text-gray-400 tabular-nums";
|
||||
el.dataset.elapsed = "";
|
||||
pipelineLabel.before(el);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Re-compute summary for affected cards
|
||||
updateReleaseSummary(data);
|
||||
}
|
||||
|
||||
// ── Elapsed time tickers ──────────────────────────────────────────
|
||||
|
||||
function formatElapsed(seconds) {
|
||||
if (seconds < 0) seconds = 0;
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m < 60) return `${m}m ${s}s`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
}
|
||||
|
||||
function updateElapsedTimers() {
|
||||
document.querySelectorAll("[data-pipeline-stage]").forEach((row) => {
|
||||
const elapsed = row.querySelector("[data-elapsed]");
|
||||
if (!elapsed) return;
|
||||
|
||||
const startedAt = row.dataset.startedAt;
|
||||
if (!startedAt) return;
|
||||
|
||||
const start = new Date(startedAt).getTime();
|
||||
if (isNaN(start)) return;
|
||||
|
||||
const completedAt = row.dataset.completedAt;
|
||||
const status = row.dataset.stageStatus;
|
||||
|
||||
if (completedAt && status !== "RUNNING" && status !== "QUEUED") {
|
||||
// Completed stage — show fixed duration
|
||||
const end = new Date(completedAt).getTime();
|
||||
if (!isNaN(end)) {
|
||||
elapsed.textContent = formatElapsed(Math.floor((end - start) / 1000));
|
||||
}
|
||||
} else {
|
||||
// Active stage — live counter
|
||||
const now = Date.now();
|
||||
elapsed.textContent = formatElapsed(Math.floor((now - start) / 1000));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Run immediately, then tick every second
|
||||
updateElapsedTimers();
|
||||
setInterval(updateElapsedTimers, 1000);
|
||||
|
||||
// Connect on page load
|
||||
connect();
|
||||
})();
|
||||
629
static/js/pipeline-builder.js
Normal file
629
static/js/pipeline-builder.js
Normal file
@@ -0,0 +1,629 @@
|
||||
/**
|
||||
* <pipeline-builder> web component
|
||||
*
|
||||
* Visual DAG builder for release pipeline stages.
|
||||
* Syncs to a hidden textarea (data-target) as JSON.
|
||||
*
|
||||
* Stage format (matches Rust serde of PipelineStage):
|
||||
* { "id": "stage-name", "depends_on": ["other"], "config": {"Deploy": {"environment": "prod"}} }
|
||||
*
|
||||
* Usage:
|
||||
* <pipeline-builder data-target="pipeline-stages"></pipeline-builder>
|
||||
* <textarea id="pipeline-stages" name="stages_json" hidden></textarea>
|
||||
*/
|
||||
|
||||
class PipelineBuilder extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.stages = [];
|
||||
this._targetId = this.dataset.target;
|
||||
this._readonly = this.dataset.readonly === "true";
|
||||
this._mode = "builder"; // "builder" | "json"
|
||||
|
||||
// Load initial value from target textarea
|
||||
const target = this._target();
|
||||
if (target && target.value.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(target.value.trim());
|
||||
this.stages = this._parseStages(parsed);
|
||||
} catch (e) {
|
||||
this._rawJson = target.value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
this._render();
|
||||
}
|
||||
|
||||
_target() {
|
||||
return this._targetId ? document.getElementById(this._targetId) : null;
|
||||
}
|
||||
|
||||
// Extract the stage type string from a config object
|
||||
_stageType(config) {
|
||||
if (!config) return "deploy";
|
||||
if (config.Deploy !== undefined) return "deploy";
|
||||
if (config.Wait !== undefined) return "wait";
|
||||
return "deploy";
|
||||
}
|
||||
|
||||
// Extract display info from config
|
||||
_configLabel(config) {
|
||||
if (!config) return "";
|
||||
if (config.Deploy) return config.Deploy.environment || "";
|
||||
if (config.Wait) return config.Wait.duration_seconds ? `${config.Wait.duration_seconds}s` : "";
|
||||
return "";
|
||||
}
|
||||
|
||||
_normalizeStage(s) {
|
||||
// Handle the new typed format: {id, depends_on, config: {Deploy: {environment}}}
|
||||
if (s.id !== undefined) {
|
||||
return {
|
||||
id: s.id || "",
|
||||
depends_on: Array.isArray(s.depends_on) ? s.depends_on : [],
|
||||
config: s.config || { Deploy: { environment: "" } },
|
||||
};
|
||||
}
|
||||
// Legacy format: {name, type, depends_on}
|
||||
const type = s.type || "deploy";
|
||||
const config = type === "wait"
|
||||
? { Wait: { duration_seconds: s.duration_seconds || 0 } }
|
||||
: { Deploy: { environment: s.environment || "" } };
|
||||
return {
|
||||
id: s.name || "",
|
||||
depends_on: Array.isArray(s.depends_on) ? s.depends_on : [],
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
_parseStages(parsed) {
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((s) => this._normalizeStage(s));
|
||||
}
|
||||
if (parsed.stages && Array.isArray(parsed.stages)) {
|
||||
return parsed.stages.map((s) => this._normalizeStage(s));
|
||||
}
|
||||
// Map format: { "id": { depends_on, config } }
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return Object.entries(parsed).map(([id, val]) =>
|
||||
this._normalizeStage({ id, ...val })
|
||||
);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
_sync() {
|
||||
const target = this._target();
|
||||
if (!target) return;
|
||||
if (this.stages.length === 0) {
|
||||
target.value = "";
|
||||
return;
|
||||
}
|
||||
// Filter out stages with no id
|
||||
const valid = this.stages.filter((s) => s.id.trim());
|
||||
target.value = JSON.stringify(valid, null, 2);
|
||||
}
|
||||
|
||||
_validate() {
|
||||
const ids = this.stages.map((s) => s.id).filter(Boolean);
|
||||
const idSet = new Set(ids);
|
||||
const errors = [];
|
||||
|
||||
if (ids.length !== idSet.size) {
|
||||
errors.push("Duplicate stage IDs detected");
|
||||
}
|
||||
|
||||
for (const s of this.stages) {
|
||||
for (const dep of s.depends_on) {
|
||||
if (!idSet.has(dep)) {
|
||||
errors.push(`"${s.id}" depends on unknown stage "${dep}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cycle detection (Kahn's algorithm)
|
||||
const inDegree = {};
|
||||
const adj = {};
|
||||
for (const s of this.stages) {
|
||||
if (!s.id) continue;
|
||||
inDegree[s.id] = 0;
|
||||
adj[s.id] = [];
|
||||
}
|
||||
for (const s of this.stages) {
|
||||
if (!s.id) continue;
|
||||
for (const dep of s.depends_on) {
|
||||
if (adj[dep]) {
|
||||
adj[dep].push(s.id);
|
||||
inDegree[s.id]++;
|
||||
}
|
||||
}
|
||||
}
|
||||
const queue = Object.keys(inDegree).filter((k) => inDegree[k] === 0);
|
||||
let visited = 0;
|
||||
while (queue.length > 0) {
|
||||
const node = queue.shift();
|
||||
visited++;
|
||||
for (const next of adj[node] || []) {
|
||||
inDegree[next]--;
|
||||
if (inDegree[next] === 0) queue.push(next);
|
||||
}
|
||||
}
|
||||
if (visited < Object.keys(inDegree).length) {
|
||||
errors.push("Cycle detected in stage dependencies");
|
||||
}
|
||||
|
||||
for (let i = 0; i < this.stages.length; i++) {
|
||||
if (!this.stages[i].id.trim()) {
|
||||
errors.push(`Stage ${i + 1} has no ID`);
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
_computeLevels() {
|
||||
const byId = {};
|
||||
for (const s of this.stages) {
|
||||
if (s.id) byId[s.id] = s;
|
||||
}
|
||||
const levels = {};
|
||||
const visited = new Set();
|
||||
|
||||
const getLevel = (id) => {
|
||||
if (levels[id] !== undefined) return levels[id];
|
||||
if (visited.has(id)) return 0;
|
||||
visited.add(id);
|
||||
const s = byId[id];
|
||||
if (!s || s.depends_on.length === 0) {
|
||||
levels[id] = 0;
|
||||
return 0;
|
||||
}
|
||||
let maxDep = 0;
|
||||
for (const dep of s.depends_on) {
|
||||
if (byId[dep]) {
|
||||
maxDep = Math.max(maxDep, getLevel(dep) + 1);
|
||||
}
|
||||
}
|
||||
levels[id] = maxDep;
|
||||
return maxDep;
|
||||
};
|
||||
|
||||
for (const s of this.stages) {
|
||||
if (s.id) getLevel(s.id);
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
_render() {
|
||||
const errors = this._validate();
|
||||
if (!this._readonly) this._sync();
|
||||
|
||||
this.innerHTML = "";
|
||||
this.className = "block";
|
||||
|
||||
// Readonly mode: just show the DAG
|
||||
if (this._readonly) {
|
||||
if (this.stages.length > 0) {
|
||||
const canvas = el("div", "dag-canvas overflow-x-auto");
|
||||
this._renderDag(canvas);
|
||||
this.append(canvas);
|
||||
} else {
|
||||
this.append(el("p", "text-xs text-gray-400 italic", "No stages defined"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mode toggle
|
||||
const toolbar = el("div", "flex items-center gap-2 mb-3");
|
||||
const builderBtn = el(
|
||||
"button",
|
||||
`text-xs px-2.5 py-1 rounded border ${this._mode === "builder" ? "bg-gray-900 text-white border-gray-900" : "border-gray-300 text-gray-600 hover:bg-gray-50"}`,
|
||||
"Builder"
|
||||
);
|
||||
builderBtn.type = "button";
|
||||
builderBtn.onclick = () => {
|
||||
if (this._mode === "json") {
|
||||
const ta = this.querySelector(".json-editor");
|
||||
if (ta) {
|
||||
try {
|
||||
const parsed = JSON.parse(ta.value);
|
||||
this.stages = this._parseStages(parsed);
|
||||
this._rawJson = null;
|
||||
} catch (e) {
|
||||
this._rawJson = ta.value;
|
||||
}
|
||||
}
|
||||
this._mode = "builder";
|
||||
this._render();
|
||||
}
|
||||
};
|
||||
const jsonBtn = el(
|
||||
"button",
|
||||
`text-xs px-2.5 py-1 rounded border ${this._mode === "json" ? "bg-gray-900 text-white border-gray-900" : "border-gray-300 text-gray-600 hover:bg-gray-50"}`,
|
||||
"JSON"
|
||||
);
|
||||
jsonBtn.type = "button";
|
||||
jsonBtn.onclick = () => {
|
||||
this._mode = "json";
|
||||
this._render();
|
||||
};
|
||||
toolbar.append(builderBtn, jsonBtn);
|
||||
|
||||
if (this._mode === "builder" && this.stages.length > 0) {
|
||||
const stageCount = el("span", "text-xs text-gray-400 ml-auto", `${this.stages.length} stage${this.stages.length !== 1 ? "s" : ""}`);
|
||||
toolbar.append(stageCount);
|
||||
}
|
||||
|
||||
this.append(toolbar);
|
||||
|
||||
if (this._mode === "json") {
|
||||
this._renderJsonMode();
|
||||
} else {
|
||||
this._renderBuilderMode(errors);
|
||||
}
|
||||
}
|
||||
|
||||
_renderJsonMode() {
|
||||
const target = this._target();
|
||||
const currentJson = this._rawJson || (target ? target.value : "") || "[]";
|
||||
|
||||
const ta = el("textarea", "json-editor w-full border border-gray-300 rounded-md px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900 resize-y");
|
||||
ta.rows = 12;
|
||||
ta.value = currentJson;
|
||||
ta.spellcheck = false;
|
||||
ta.oninput = () => {
|
||||
const t = this._target();
|
||||
if (t) t.value = ta.value;
|
||||
this._updateJsonErrors(ta.value);
|
||||
};
|
||||
|
||||
const errBox = el("div", "json-errors mt-2");
|
||||
this.append(ta, errBox);
|
||||
this._updateJsonErrors(currentJson);
|
||||
}
|
||||
|
||||
_updateJsonErrors(value) {
|
||||
const errBox = this.querySelector(".json-errors");
|
||||
if (!errBox) return;
|
||||
errBox.innerHTML = "";
|
||||
if (!value.trim()) return;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
const stages = Array.isArray(parsed) ? parsed : (parsed.stages || []);
|
||||
const ids = stages.map((s) => s.id || s.name).filter(Boolean);
|
||||
if (new Set(ids).size !== ids.length) {
|
||||
errBox.append(el("p", "text-xs text-amber-600", "Warning: duplicate stage IDs"));
|
||||
}
|
||||
} catch (e) {
|
||||
errBox.append(el("p", "text-xs text-red-600", "Invalid JSON: " + e.message));
|
||||
}
|
||||
}
|
||||
|
||||
_renderBuilderMode(errors) {
|
||||
if (this.stages.length > 0) {
|
||||
const dagBox = el("div", "mb-4 border border-gray-200 rounded-lg overflow-hidden");
|
||||
const canvas = el("div", "dag-canvas p-4 bg-gray-50 overflow-x-auto");
|
||||
canvas.style.minHeight = "80px";
|
||||
this._renderDag(canvas);
|
||||
dagBox.append(canvas);
|
||||
this.append(dagBox);
|
||||
}
|
||||
|
||||
const list = el("div", "space-y-2 mb-3");
|
||||
for (let i = 0; i < this.stages.length; i++) {
|
||||
list.append(this._renderStageCard(i));
|
||||
}
|
||||
this.append(list);
|
||||
|
||||
if (errors.length > 0) {
|
||||
const errBox = el("div", "mb-3 p-3 bg-red-50 border border-red-200 rounded-md");
|
||||
for (const err of errors) {
|
||||
errBox.append(el("p", "text-xs text-red-700", err));
|
||||
}
|
||||
this.append(errBox);
|
||||
}
|
||||
|
||||
const addBtn = el("button", "text-sm px-3 py-1.5 rounded border border-dashed border-gray-300 text-gray-500 hover:border-gray-400 hover:text-gray-700 w-full", "+ Add stage");
|
||||
addBtn.type = "button";
|
||||
addBtn.onmousedown = (e) => e.preventDefault();
|
||||
addBtn.onclick = () => {
|
||||
clearTimeout(this._blurTimer);
|
||||
this.stages.push({ id: "", depends_on: [], config: { Deploy: { environment: "" } } });
|
||||
this._render();
|
||||
requestAnimationFrame(() => {
|
||||
const inputs = this.querySelectorAll('input[data-field="id"]');
|
||||
if (inputs.length) inputs[inputs.length - 1].focus();
|
||||
});
|
||||
};
|
||||
this.append(addBtn);
|
||||
}
|
||||
|
||||
_renderStageCard(index) {
|
||||
const stage = this.stages[index];
|
||||
const type = this._stageType(stage.config);
|
||||
const otherIds = this.stages
|
||||
.map((s, i) => (i !== index && s.id.trim() ? s.id.trim() : null))
|
||||
.filter(Boolean);
|
||||
|
||||
const card = el("div", "border border-gray-200 rounded-md bg-white");
|
||||
|
||||
// Header row
|
||||
const header = el("div", "flex items-center gap-2 px-3 py-2");
|
||||
const badge = el("span", "text-xs font-mono text-gray-400 w-5 shrink-0", `${index + 1}`);
|
||||
|
||||
// ID input
|
||||
const idInput = el("input", "flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400 min-w-0");
|
||||
idInput.type = "text";
|
||||
idInput.value = stage.id;
|
||||
idInput.placeholder = "stage id";
|
||||
idInput.dataset.field = "id";
|
||||
idInput.oninput = () => {
|
||||
this.stages[index].id = idInput.value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-");
|
||||
idInput.value = this.stages[index].id;
|
||||
this._sync();
|
||||
this._renderDagIfPresent();
|
||||
};
|
||||
idInput.onblur = () => {
|
||||
this._blurTimer = setTimeout(() => this._render(), 150);
|
||||
};
|
||||
|
||||
// Type select (deploy / wait)
|
||||
const typeSelect = el("select", "border border-gray-200 rounded px-2 py-1 text-xs bg-white shrink-0");
|
||||
for (const t of ["deploy", "wait"]) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = t;
|
||||
opt.textContent = t;
|
||||
opt.selected = type === t;
|
||||
typeSelect.append(opt);
|
||||
}
|
||||
typeSelect.onmousedown = (e) => e.stopPropagation();
|
||||
typeSelect.onchange = () => {
|
||||
clearTimeout(this._blurTimer);
|
||||
if (typeSelect.value === "wait") {
|
||||
this.stages[index].config = { Wait: { duration_seconds: 0 } };
|
||||
} else {
|
||||
this.stages[index].config = { Deploy: { environment: "" } };
|
||||
}
|
||||
this._render();
|
||||
};
|
||||
|
||||
// Remove button
|
||||
const removeBtn = el("button", "text-gray-400 hover:text-red-500 shrink-0 p-1");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.innerHTML = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>`;
|
||||
removeBtn.title = "Remove stage";
|
||||
removeBtn.onmousedown = (e) => e.preventDefault();
|
||||
removeBtn.onclick = () => {
|
||||
clearTimeout(this._blurTimer);
|
||||
const removedId = this.stages[index].id;
|
||||
this.stages.splice(index, 1);
|
||||
for (const s of this.stages) {
|
||||
s.depends_on = s.depends_on.filter((d) => d !== removedId);
|
||||
}
|
||||
this._render();
|
||||
};
|
||||
|
||||
header.append(badge, idInput, typeSelect, removeBtn);
|
||||
card.append(header);
|
||||
|
||||
// Config row (type-specific fields)
|
||||
const configRow = el("div", "px-3 pb-2 flex items-center gap-2 flex-wrap");
|
||||
if (type === "deploy") {
|
||||
const envLabel = el("span", "text-xs text-gray-500 shrink-0", "env:");
|
||||
const envInput = el("input", "border border-gray-200 rounded px-2 py-1 text-xs w-32 focus:outline-none focus:ring-1 focus:ring-gray-400");
|
||||
envInput.type = "text";
|
||||
envInput.value = (stage.config.Deploy && stage.config.Deploy.environment) || "";
|
||||
envInput.placeholder = "environment";
|
||||
envInput.onmousedown = (e) => e.stopPropagation();
|
||||
envInput.oninput = () => {
|
||||
if (!this.stages[index].config.Deploy) this.stages[index].config = { Deploy: { environment: "" } };
|
||||
this.stages[index].config.Deploy.environment = envInput.value.trim();
|
||||
this._sync();
|
||||
};
|
||||
envInput.onblur = () => {
|
||||
this._blurTimer = setTimeout(() => this._render(), 150);
|
||||
};
|
||||
configRow.append(envLabel, envInput);
|
||||
} else if (type === "wait") {
|
||||
const durLabel = el("span", "text-xs text-gray-500 shrink-0", "wait:");
|
||||
const durInput = el("input", "border border-gray-200 rounded px-2 py-1 text-xs w-20 focus:outline-none focus:ring-1 focus:ring-gray-400");
|
||||
durInput.type = "number";
|
||||
durInput.min = "0";
|
||||
durInput.value = (stage.config.Wait && stage.config.Wait.duration_seconds) || 0;
|
||||
durInput.placeholder = "seconds";
|
||||
durInput.onmousedown = (e) => e.stopPropagation();
|
||||
durInput.oninput = () => {
|
||||
if (!this.stages[index].config.Wait) this.stages[index].config = { Wait: { duration_seconds: 0 } };
|
||||
this.stages[index].config.Wait.duration_seconds = parseInt(durInput.value) || 0;
|
||||
this._sync();
|
||||
};
|
||||
durInput.onblur = () => {
|
||||
this._blurTimer = setTimeout(() => this._render(), 150);
|
||||
};
|
||||
const secLabel = el("span", "text-xs text-gray-400", "seconds");
|
||||
configRow.append(durLabel, durInput, secLabel);
|
||||
}
|
||||
card.append(configRow);
|
||||
|
||||
// Dependencies row
|
||||
if (otherIds.length > 0) {
|
||||
const depsRow = el("div", "px-3 pb-2 flex items-center gap-2 flex-wrap");
|
||||
const label = el("span", "text-xs text-gray-500 shrink-0", "after:");
|
||||
depsRow.append(label);
|
||||
|
||||
for (const dep of otherIds) {
|
||||
const isSelected = stage.depends_on.includes(dep);
|
||||
const chip = el(
|
||||
"button",
|
||||
`text-xs px-2 py-0.5 rounded-full border transition-colors ${isSelected ? "bg-gray-900 text-white border-gray-900" : "border-gray-300 text-gray-500 hover:border-gray-400"}`,
|
||||
dep
|
||||
);
|
||||
chip.type = "button";
|
||||
chip.onmousedown = (e) => e.preventDefault();
|
||||
chip.onclick = () => {
|
||||
clearTimeout(this._blurTimer);
|
||||
if (isSelected) {
|
||||
this.stages[index].depends_on = this.stages[index].depends_on.filter((d) => d !== dep);
|
||||
} else {
|
||||
this.stages[index].depends_on.push(dep);
|
||||
}
|
||||
this._render();
|
||||
};
|
||||
depsRow.append(chip);
|
||||
}
|
||||
card.append(depsRow);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
_renderDagIfPresent() {
|
||||
const canvas = this.querySelector(".dag-canvas");
|
||||
if (canvas) this._renderDag(canvas);
|
||||
}
|
||||
|
||||
_renderDag(canvas) {
|
||||
canvas.innerHTML = "";
|
||||
const named = this.stages.filter((s) => s.id.trim());
|
||||
if (named.length === 0) {
|
||||
canvas.append(el("p", "text-xs text-gray-400 italic", "Add stages to see the pipeline graph"));
|
||||
return;
|
||||
}
|
||||
|
||||
const levels = this._computeLevels();
|
||||
const maxLevel = Math.max(0, ...Object.values(levels));
|
||||
|
||||
const columns = [];
|
||||
for (let l = 0; l <= maxLevel; l++) columns.push([]);
|
||||
for (const s of named) {
|
||||
const lvl = levels[s.id] || 0;
|
||||
columns[lvl].push(s);
|
||||
}
|
||||
|
||||
const svgNS = "http://www.w3.org/2000/svg";
|
||||
const NODE_W = 120;
|
||||
const NODE_H = 40;
|
||||
const COL_GAP = 60;
|
||||
const ROW_GAP = 12;
|
||||
|
||||
const positions = {};
|
||||
let totalW = 0;
|
||||
let totalH = 0;
|
||||
|
||||
for (let col = 0; col <= maxLevel; col++) {
|
||||
const stages = columns[col];
|
||||
for (let row = 0; row < stages.length; row++) {
|
||||
const x = col * (NODE_W + COL_GAP);
|
||||
const y = row * (NODE_H + ROW_GAP);
|
||||
positions[stages[row].id] = { x, y };
|
||||
totalW = Math.max(totalW, x + NODE_W);
|
||||
totalH = Math.max(totalH, y + NODE_H);
|
||||
}
|
||||
}
|
||||
|
||||
const PAD = 8;
|
||||
const svgW = totalW + PAD * 2;
|
||||
const svgH = totalH + PAD * 2;
|
||||
|
||||
const svg = document.createElementNS(svgNS, "svg");
|
||||
svg.setAttribute("width", svgW);
|
||||
svg.setAttribute("height", svgH);
|
||||
svg.style.display = "block";
|
||||
|
||||
// Arrowhead marker
|
||||
const defs = document.createElementNS(svgNS, "defs");
|
||||
const marker = document.createElementNS(svgNS, "marker");
|
||||
marker.setAttribute("id", "pb-arrow");
|
||||
marker.setAttribute("viewBox", "0 0 10 10");
|
||||
marker.setAttribute("refX", "10");
|
||||
marker.setAttribute("refY", "5");
|
||||
marker.setAttribute("markerWidth", "6");
|
||||
marker.setAttribute("markerHeight", "6");
|
||||
marker.setAttribute("orient", "auto-start-reverse");
|
||||
const arrowPath = document.createElementNS(svgNS, "path");
|
||||
arrowPath.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
|
||||
arrowPath.setAttribute("fill", "#9ca3af");
|
||||
marker.append(arrowPath);
|
||||
defs.append(marker);
|
||||
svg.append(defs);
|
||||
|
||||
// Draw edges
|
||||
for (const s of named) {
|
||||
const to = positions[s.id];
|
||||
if (!to) continue;
|
||||
for (const dep of s.depends_on) {
|
||||
const from = positions[dep];
|
||||
if (!from) continue;
|
||||
const line = document.createElementNS(svgNS, "line");
|
||||
line.setAttribute("x1", from.x + NODE_W + PAD);
|
||||
line.setAttribute("y1", from.y + NODE_H / 2 + PAD);
|
||||
line.setAttribute("x2", to.x + PAD);
|
||||
line.setAttribute("y2", to.y + NODE_H / 2 + PAD);
|
||||
line.setAttribute("stroke", "#d1d5db");
|
||||
line.setAttribute("stroke-width", "2");
|
||||
line.setAttribute("marker-end", "url(#pb-arrow)");
|
||||
svg.append(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw nodes
|
||||
const TYPE_COLORS = {
|
||||
deploy: { bg: "#dbeafe", border: "#93c5fd", text: "#1e40af" },
|
||||
wait: { bg: "#fef3c7", border: "#fcd34d", text: "#92400e" },
|
||||
};
|
||||
|
||||
for (const s of named) {
|
||||
const pos = positions[s.id];
|
||||
if (!pos) continue;
|
||||
const type = this._stageType(s.config);
|
||||
const colors = TYPE_COLORS[type] || TYPE_COLORS.deploy;
|
||||
const label = this._configLabel(s.config);
|
||||
|
||||
const rect = document.createElementNS(svgNS, "rect");
|
||||
rect.setAttribute("x", pos.x + PAD);
|
||||
rect.setAttribute("y", pos.y + PAD);
|
||||
rect.setAttribute("width", NODE_W);
|
||||
rect.setAttribute("height", NODE_H);
|
||||
rect.setAttribute("rx", "6");
|
||||
rect.setAttribute("fill", colors.bg);
|
||||
rect.setAttribute("stroke", colors.border);
|
||||
rect.setAttribute("stroke-width", "1.5");
|
||||
svg.append(rect);
|
||||
|
||||
// Stage ID text
|
||||
const text = document.createElementNS(svgNS, "text");
|
||||
text.setAttribute("x", pos.x + NODE_W / 2 + PAD);
|
||||
text.setAttribute("y", pos.y + NODE_H / 2 + PAD + (label ? -4 : 0));
|
||||
text.setAttribute("text-anchor", "middle");
|
||||
text.setAttribute("dominant-baseline", "middle");
|
||||
text.setAttribute("fill", colors.text);
|
||||
text.setAttribute("font-size", "12");
|
||||
text.setAttribute("font-weight", "600");
|
||||
text.textContent = s.id.length > 14 ? s.id.slice(0, 13) + "…" : s.id;
|
||||
svg.append(text);
|
||||
|
||||
// Config label (environment or duration)
|
||||
if (label) {
|
||||
const sub = document.createElementNS(svgNS, "text");
|
||||
sub.setAttribute("x", pos.x + NODE_W / 2 + PAD);
|
||||
sub.setAttribute("y", pos.y + NODE_H / 2 + 10 + PAD);
|
||||
sub.setAttribute("text-anchor", "middle");
|
||||
sub.setAttribute("dominant-baseline", "middle");
|
||||
sub.setAttribute("fill", colors.text);
|
||||
sub.setAttribute("font-size", "9");
|
||||
sub.setAttribute("opacity", "0.7");
|
||||
sub.textContent = label;
|
||||
svg.append(sub);
|
||||
}
|
||||
}
|
||||
|
||||
canvas.append(svg);
|
||||
}
|
||||
}
|
||||
|
||||
function el(tag, className, text) {
|
||||
const e = document.createElement(tag);
|
||||
if (className) e.className = className;
|
||||
if (text) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
|
||||
customElements.define("pipeline-builder", PipelineBuilder);
|
||||
@@ -2,34 +2,30 @@
|
||||
* <swim-lanes> web component
|
||||
*
|
||||
* Renders colored vertical bars alongside a release timeline.
|
||||
* Each bar grows from the BOTTOM of the timeline upward to the top edge
|
||||
* of the last release card deployed to that environment.
|
||||
* Labels are rendered at the bottom of each bar, rotated vertically.
|
||||
* Bars grow from the BOTTOM of the timeline upward to the dot position
|
||||
* (avatar center) of the relevant release card.
|
||||
*
|
||||
* Usage:
|
||||
* <swim-lanes>
|
||||
* <div data-lane="staging"></div>
|
||||
* <div data-lane="prod"></div>
|
||||
* <div data-swimlane-timeline>
|
||||
* <div data-release data-envs="staging,prod">...</div>
|
||||
* <div data-release data-envs="staging">...</div>
|
||||
* </div>
|
||||
* </swim-lanes>
|
||||
* In-flight deployments (QUEUED/RUNNING/ASSIGNED) show a hatched segment
|
||||
* with direction arrows: ▲ for forward deploy, ▼ for rollback.
|
||||
*
|
||||
* data-envs format: "env:STATUS,env:STATUS" e.g. "staging:SUCCEEDED,prod:QUEUED"
|
||||
*/
|
||||
|
||||
const ENV_COLORS = {
|
||||
prod: ["#f472b6", "#ec4899"],
|
||||
production: ["#f472b6", "#ec4899"],
|
||||
preprod: ["#fdba74", "#f97316"],
|
||||
"pre-prod": ["#fdba74", "#f97316"],
|
||||
staging: ["#fbbf24", "#ca8a04"],
|
||||
stage: ["#fbbf24", "#ca8a04"],
|
||||
dev: ["#a78bfa", "#7c3aed"],
|
||||
development: ["#a78bfa", "#7c3aed"],
|
||||
test: ["#67e8f9", "#0891b2"],
|
||||
prod: ["#ec4899", "#fce7f3"],
|
||||
production: ["#ec4899", "#fce7f3"],
|
||||
preprod: ["#f97316", "#ffedd5"],
|
||||
"pre-prod": ["#f97316", "#ffedd5"],
|
||||
staging: ["#eab308", "#fef9c3"],
|
||||
stage: ["#eab308", "#fef9c3"],
|
||||
dev: ["#8b5cf6", "#ede9fe"],
|
||||
development: ["#8b5cf6", "#ede9fe"],
|
||||
test: ["#06b6d4", "#cffafe"],
|
||||
};
|
||||
|
||||
const DEFAULT_COLORS = ["#d1d5db", "#9ca3af"];
|
||||
const DEFAULT_COLORS = ["#6b7280", "#e5e7eb"];
|
||||
const IN_FLIGHT = new Set(["QUEUED", "RUNNING", "ASSIGNED"]);
|
||||
const DEPLOYED = new Set(["SUCCEEDED"]);
|
||||
|
||||
function envColors(name) {
|
||||
const lower = name.toLowerCase();
|
||||
@@ -40,17 +36,80 @@ function envColors(name) {
|
||||
return DEFAULT_COLORS;
|
||||
}
|
||||
|
||||
function parseEnvs(raw) {
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const colon = entry.indexOf(":");
|
||||
if (colon === -1) return { env: entry, status: "SUCCEEDED" };
|
||||
return { env: entry.slice(0, colon), status: entry.slice(colon + 1) };
|
||||
});
|
||||
}
|
||||
|
||||
function dotY(card, timelineTop) {
|
||||
const avatar = card.querySelector("[data-avatar]");
|
||||
const anchor = avatar || card;
|
||||
const r = anchor.getBoundingClientRect();
|
||||
return r.top + r.height / 2 - timelineTop;
|
||||
}
|
||||
|
||||
/** Create an inline SVG data URL for a diagonal hatch pattern */
|
||||
function hatchPattern(color, bgColor) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8">
|
||||
<rect width="8" height="8" fill="${bgColor}"/>
|
||||
<path d="M-2,2 l4,-4 M0,8 l8,-8 M6,10 l4,-4" stroke="${color}" stroke-width="1.5" opacity="0.6"/>
|
||||
</svg>`;
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
}
|
||||
|
||||
// Inject CSS once
|
||||
if (!document.getElementById("swim-lane-styles")) {
|
||||
const style = document.createElement("style");
|
||||
style.id = "swim-lane-styles";
|
||||
style.textContent = `
|
||||
@keyframes lane-pulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
.lane-pulse {
|
||||
animation: lane-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
.lane-arrow {
|
||||
font-size: 9px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
const BAR_WIDTH = 20;
|
||||
const BAR_GAP = 4;
|
||||
const DOT_SIZE = 12;
|
||||
|
||||
class SwimLanes extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.style.display = "flex";
|
||||
this._render();
|
||||
this._ro = new ResizeObserver(() => this._render());
|
||||
const timeline = this.querySelector("[data-swimlane-timeline]");
|
||||
if (timeline) {
|
||||
this._ro.observe(timeline);
|
||||
// Re-render when details elements are toggled (show/hide commits)
|
||||
timeline.addEventListener("toggle", () => this._render(), true);
|
||||
}
|
||||
// Lanes live in [data-swimlane-gutter], a CSS grid column to the
|
||||
// left of the timeline. The grid column width is pre-set in the
|
||||
// template (lane_count * 18 + 8 px) so there is no layout shift.
|
||||
requestAnimationFrame(() => {
|
||||
this._render();
|
||||
this._ro = new ResizeObserver(() => this._render());
|
||||
const timeline = this.querySelector("[data-swimlane-timeline]");
|
||||
if (timeline) {
|
||||
this._ro.observe(timeline);
|
||||
timeline.addEventListener("toggle", () => this._render(), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@@ -65,37 +124,70 @@ class SwimLanes extends HTMLElement {
|
||||
if (cards.length === 0) return;
|
||||
|
||||
const timelineRect = timeline.getBoundingClientRect();
|
||||
const lanes = Array.from(this.querySelectorAll("[data-lane]"));
|
||||
if (timelineRect.height === 0) return;
|
||||
const gutter = this.querySelector("[data-swimlane-gutter]");
|
||||
const lanes = gutter
|
||||
? Array.from(gutter.querySelectorAll("[data-lane]"))
|
||||
: Array.from(this.querySelectorAll("[data-lane]"));
|
||||
|
||||
for (const lane of lanes) {
|
||||
const env = lane.dataset.lane;
|
||||
const [barColor, labelColor] = envColors(env);
|
||||
const [barColor, lightColor] = envColors(env);
|
||||
|
||||
// Find the LAST (bottommost) card deployed to this env
|
||||
let lastCard = null;
|
||||
for (const card of cards) {
|
||||
const envs = (card.dataset.envs || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (envs.includes(env)) lastCard = card;
|
||||
let deployedCard = null;
|
||||
let deployedIdx = -1;
|
||||
let flightCard = null;
|
||||
let flightIdx = -1;
|
||||
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
const entries = parseEnvs(cards[i].dataset.envs);
|
||||
for (const entry of entries) {
|
||||
if (entry.env !== env) continue;
|
||||
if (DEPLOYED.has(entry.status) && !deployedCard) {
|
||||
deployedCard = cards[i];
|
||||
deployedIdx = i;
|
||||
}
|
||||
if (IN_FLIGHT.has(entry.status) && !flightCard) {
|
||||
flightCard = cards[i];
|
||||
flightIdx = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bar height: from bottom of timeline up to top of the last deployed card
|
||||
let barHeight = 0;
|
||||
if (lastCard) {
|
||||
const cardRect = lastCard.getBoundingClientRect();
|
||||
barHeight = timelineRect.bottom - cardRect.top;
|
||||
const timelineH = timelineRect.height;
|
||||
|
||||
// Card top edge (Y relative to timeline) — bars extend to the card top
|
||||
const deployedTop = deployedCard
|
||||
? deployedCard.getBoundingClientRect().top - timelineRect.top
|
||||
: null;
|
||||
const flightTop = flightCard
|
||||
? flightCard.getBoundingClientRect().top - timelineRect.top
|
||||
: null;
|
||||
// Dot center Y — used for arrow placement
|
||||
const flightDot = flightCard
|
||||
? dotY(flightCard, timelineRect.top)
|
||||
: null;
|
||||
|
||||
// Solid bar: from bottom up to the card top of the LOWER card.
|
||||
// If both exist, only go to whichever is lower (further down) to avoid overlap.
|
||||
let solidBarFromBottom = 0;
|
||||
if (deployedTop !== null && flightTop !== null) {
|
||||
const lowerTop = Math.max(deployedTop, flightTop);
|
||||
solidBarFromBottom = timelineH - lowerTop;
|
||||
} else if (deployedTop !== null) {
|
||||
solidBarFromBottom = timelineH - deployedTop;
|
||||
}
|
||||
|
||||
// Style the lane container
|
||||
lane.style.width = "14px";
|
||||
lane.style.marginRight = "4px";
|
||||
// Style lane container — width/gap only; height comes from the grid row
|
||||
lane.style.width = BAR_WIDTH + "px";
|
||||
lane.style.marginRight = BAR_GAP + "px";
|
||||
lane.style.position = "relative";
|
||||
lane.style.minHeight = timelineRect.height + "px";
|
||||
lane.style.flexShrink = "0";
|
||||
|
||||
// Create or update bar (anchored to bottom)
|
||||
const hasHatch = !!flightCard;
|
||||
const hasSolid = solidBarFromBottom > 0;
|
||||
const R = "9999px";
|
||||
|
||||
// ── Solid bar ──
|
||||
let bar = lane.querySelector(".lane-bar");
|
||||
if (!bar) {
|
||||
bar = document.createElement("div");
|
||||
@@ -104,26 +196,85 @@ class SwimLanes extends HTMLElement {
|
||||
bar.style.bottom = "0";
|
||||
bar.style.left = "0";
|
||||
bar.style.width = "100%";
|
||||
bar.style.borderRadius = "9999px";
|
||||
lane.appendChild(bar);
|
||||
}
|
||||
bar.style.height = barHeight + "px";
|
||||
bar.style.height = Math.max(solidBarFromBottom, 0) + "px";
|
||||
bar.style.backgroundColor = barColor;
|
||||
// Round bottom always; round top only if no hatch connects above
|
||||
bar.style.borderRadius = hasHatch
|
||||
? `0 0 ${R} ${R}`
|
||||
: R;
|
||||
|
||||
// ── Hatched segment for in-flight ──
|
||||
let hatch = lane.querySelector(".lane-hatch");
|
||||
let arrow = lane.querySelector(".lane-arrow");
|
||||
if (flightCard) {
|
||||
const isForward = deployedIdx === -1 || flightIdx < deployedIdx;
|
||||
|
||||
// Hatched segment spans between the two card tops (or bottom of timeline)
|
||||
const anchorY = deployedTop !== null ? deployedTop : timelineH;
|
||||
const topY = Math.min(anchorY, flightTop);
|
||||
const bottomY = Math.max(anchorY, flightTop);
|
||||
const segHeight = bottomY - topY;
|
||||
|
||||
if (!hatch) {
|
||||
hatch = document.createElement("div");
|
||||
hatch.className = "lane-hatch lane-pulse";
|
||||
hatch.style.position = "absolute";
|
||||
hatch.style.left = "0";
|
||||
hatch.style.width = "100%";
|
||||
hatch.style.backgroundSize = "8px 8px";
|
||||
hatch.style.backgroundRepeat = "repeat";
|
||||
lane.appendChild(hatch);
|
||||
}
|
||||
hatch.style.backgroundImage = isForward
|
||||
? hatchPattern(barColor, lightColor)
|
||||
: hatchPattern("#f59e0b", "#fef3c7");
|
||||
hatch.style.top = topY + "px";
|
||||
hatch.style.height = Math.max(segHeight, 4) + "px";
|
||||
hatch.style.display = "";
|
||||
// Round top always; round bottom only if no solid bar connects below
|
||||
hatch.style.borderRadius = hasSolid
|
||||
? `${R} ${R} 0 0`
|
||||
: R;
|
||||
|
||||
// Direction arrow:
|
||||
// Forward (▲): shown at the in-flight card (destination)
|
||||
// Rollback (▼): shown at the deployed card (source we're rolling back from)
|
||||
const arrowDotY = isForward
|
||||
? flightDot
|
||||
: dotY(deployedCard, timelineRect.top);
|
||||
if (!arrow) {
|
||||
arrow = document.createElement("div");
|
||||
arrow.className = "lane-arrow";
|
||||
lane.appendChild(arrow);
|
||||
}
|
||||
arrow.textContent = isForward ? "\u25B2" : "\u25BC";
|
||||
arrow.style.color = isForward ? barColor : "#f59e0b";
|
||||
arrow.style.top = arrowDotY - 5 + "px";
|
||||
arrow.style.display = "";
|
||||
} else {
|
||||
if (hatch) hatch.style.display = "none";
|
||||
if (arrow) arrow.style.display = "none";
|
||||
}
|
||||
|
||||
// ── Dots ──
|
||||
// The arrow replaces the dot on one card:
|
||||
// Forward: arrow on in-flight card (destination)
|
||||
// Rollback: arrow on deployed card (source)
|
||||
const arrowCard = flightCard
|
||||
? (deployedIdx === -1 || flightIdx < deployedIdx ? flightCard : deployedCard)
|
||||
: null;
|
||||
|
||||
// Place dots on the lane for each card deployed to this env
|
||||
const existingDots = lane.querySelectorAll(".lane-dot");
|
||||
let dotIndex = 0;
|
||||
for (const card of cards) {
|
||||
const envs = (card.dataset.envs || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (!envs.includes(env)) continue;
|
||||
const entries = parseEnvs(card.dataset.envs);
|
||||
const match = entries.find((e) => e.env === env);
|
||||
if (!match) continue;
|
||||
if (card === arrowCard) continue; // arrow shown instead of dot
|
||||
|
||||
const avatar = card.querySelector("[data-avatar]");
|
||||
const anchor = avatar || card;
|
||||
const anchorRect = anchor.getBoundingClientRect();
|
||||
const centerY = anchorRect.top + anchorRect.height / 2 - timelineRect.top;
|
||||
const cy = dotY(card, timelineRect.top);
|
||||
|
||||
let dot = existingDots[dotIndex];
|
||||
if (!dot) {
|
||||
@@ -132,41 +283,23 @@ class SwimLanes extends HTMLElement {
|
||||
dot.style.position = "absolute";
|
||||
dot.style.left = "50%";
|
||||
dot.style.transform = "translateX(-50%)";
|
||||
dot.style.width = "8px";
|
||||
dot.style.height = "8px";
|
||||
dot.style.width = DOT_SIZE + "px";
|
||||
dot.style.height = DOT_SIZE + "px";
|
||||
dot.style.borderRadius = "50%";
|
||||
dot.style.backgroundColor = "#fff";
|
||||
dot.style.border = "2px solid " + barColor;
|
||||
dot.style.zIndex = "1";
|
||||
dot.style.zIndex = "2";
|
||||
lane.appendChild(dot);
|
||||
}
|
||||
dot.style.top = centerY - 4 + "px";
|
||||
dot.style.borderColor = barColor;
|
||||
dot.style.top = cy - DOT_SIZE / 2 + "px";
|
||||
dot.style.backgroundColor = "#fff";
|
||||
dot.style.border = "2px solid " + barColor;
|
||||
dot.classList.remove("lane-pulse");
|
||||
dotIndex++;
|
||||
}
|
||||
// Remove extra dots from previous renders
|
||||
for (let i = dotIndex; i < existingDots.length; i++) {
|
||||
existingDots[i].remove();
|
||||
}
|
||||
|
||||
// Create or update label (at the very bottom, below bars)
|
||||
let label = lane.querySelector(".lane-label");
|
||||
if (!label) {
|
||||
label = document.createElement("span");
|
||||
label.className = "lane-label";
|
||||
label.style.position = "absolute";
|
||||
label.style.bottom = "-4px";
|
||||
label.style.left = "50%";
|
||||
label.style.writingMode = "vertical-lr";
|
||||
label.style.transform = "translateX(-50%) translateY(100%) rotate(180deg)";
|
||||
label.style.fontSize = "10px";
|
||||
label.style.fontWeight = "500";
|
||||
label.style.whiteSpace = "nowrap";
|
||||
label.style.paddingTop = "6px";
|
||||
lane.appendChild(label);
|
||||
}
|
||||
label.textContent = env;
|
||||
label.style.color = labelColor;
|
||||
// Labels are rendered server-side above the gutter (no JS needed).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user