feat: add many things

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-08 23:00:03 +01:00
parent 45353089c2
commit 5a5f9a3003
104 changed files with 23417 additions and 2027 deletions

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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
View 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();
})();

View 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);

View File

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