Files
client/static/js/swim-lanes.js
2026-03-08 23:00:03 +01:00

308 lines
10 KiB
JavaScript

/**
* <swim-lanes> web component
*
* Renders colored vertical bars alongside a release timeline.
* Bars grow from the BOTTOM of the timeline upward to the dot position
* (avatar center) of the relevant release card.
*
* 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: ["#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 = ["#6b7280", "#e5e7eb"];
const IN_FLIGHT = new Set(["QUEUED", "RUNNING", "ASSIGNED"]);
const DEPLOYED = new Set(["SUCCEEDED"]);
function envColors(name) {
const lower = name.toLowerCase();
if (ENV_COLORS[lower]) return ENV_COLORS[lower];
for (const [key, colors] of Object.entries(ENV_COLORS)) {
if (lower.includes(key)) return colors;
}
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() {
// 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() {
if (this._ro) this._ro.disconnect();
}
_render() {
const timeline = this.querySelector("[data-swimlane-timeline]");
if (!timeline) return;
const cards = Array.from(timeline.querySelectorAll("[data-release]"));
if (cards.length === 0) return;
const timelineRect = timeline.getBoundingClientRect();
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, lightColor] = envColors(env);
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;
}
}
}
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 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";
const hasHatch = !!flightCard;
const hasSolid = solidBarFromBottom > 0;
const R = "9999px";
// ── Solid bar ──
let bar = lane.querySelector(".lane-bar");
if (!bar) {
bar = document.createElement("div");
bar.className = "lane-bar";
bar.style.position = "absolute";
bar.style.bottom = "0";
bar.style.left = "0";
bar.style.width = "100%";
lane.appendChild(bar);
}
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;
const existingDots = lane.querySelectorAll(".lane-dot");
let dotIndex = 0;
for (const card of cards) {
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 cy = dotY(card, timelineRect.top);
let dot = existingDots[dotIndex];
if (!dot) {
dot = document.createElement("div");
dot.className = "lane-dot";
dot.style.position = "absolute";
dot.style.left = "50%";
dot.style.transform = "translateX(-50%)";
dot.style.width = DOT_SIZE + "px";
dot.style.height = DOT_SIZE + "px";
dot.style.borderRadius = "50%";
dot.style.zIndex = "2";
lane.appendChild(dot);
}
dot.style.top = cy - DOT_SIZE / 2 + "px";
dot.style.backgroundColor = "#fff";
dot.style.border = "2px solid " + barColor;
dot.classList.remove("lane-pulse");
dotIndex++;
}
for (let i = dotIndex; i < existingDots.length; i++) {
existingDots[i].remove();
}
// Labels are rendered server-side above the gutter (no JS needed).
}
}
}
customElements.define("swim-lanes", SwimLanes);