Files
client/static/js/live-events.js
2026-03-08 23:00:03 +01:00

702 lines
24 KiB
JavaScript

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