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

@@ -0,0 +1,469 @@
<svelte:options customElement="release-logs" />
<script>
let { url = "" } = $props();
// State
let destinations = $state({});
let activeTab = $state(null);
let connected = $state(false);
let done = $state(false);
let autoScroll = $state(true);
let showTimestamps = $state(true);
let expanded = $state(false);
let logContainer = $state(null);
// Derived: sorted destination names
let destNames = $derived(Object.keys(destinations).sort());
let activeLines = $derived(activeTab && destinations[activeTab] ? destinations[activeTab] : []);
function connect() {
if (!url) return;
const es = new EventSource(url);
connected = true;
es.addEventListener("log", (e) => {
try {
const data = JSON.parse(e.data);
const dest = data.destination || "unknown";
if (!destinations[dest]) {
destinations[dest] = [];
if (!activeTab) activeTab = dest;
}
destinations[dest] = [
...destinations[dest],
{
line: data.line,
timestamp: data.timestamp,
channel: data.channel || "stdout",
},
];
if (autoScroll) {
requestAnimationFrame(() => {
if (logContainer) {
logContainer.scrollTop = logContainer.scrollHeight;
}
});
}
} catch (err) {
console.warn("[release-logs] bad log event:", err);
}
});
es.addEventListener("status", (e) => {
try {
const data = JSON.parse(e.data);
const dest = data.destination || "unknown";
if (!destinations[dest]) {
destinations[dest] = [];
if (!activeTab) activeTab = dest;
}
destinations[dest] = [
...destinations[dest],
{
line: `── ${data.status} ──`,
timestamp: "",
channel: "status",
},
];
} catch {}
});
es.addEventListener("done", () => {
done = true;
});
es.addEventListener("error", () => {
connected = false;
es.close();
});
return () => {
es.close();
connected = false;
};
}
$effect(() => {
if (url) {
const cleanup = connect();
return cleanup;
}
});
function handleScroll() {
if (!logContainer) return;
const atBottom =
logContainer.scrollHeight - logContainer.scrollTop - logContainer.clientHeight < 40;
autoScroll = atBottom;
}
function scrollToBottom() {
if (logContainer) {
logContainer.scrollTop = logContainer.scrollHeight;
autoScroll = true;
}
}
function parseTs(ts) {
if (!ts) return null;
const n = Number(ts);
if (Number.isFinite(n) && n > 1e12) return n;
const d = new Date(ts);
return isNaN(d.getTime()) ? null : d.getTime();
}
function formatElapsed(ts, baseTs) {
const ms = parseTs(ts);
if (ms === null || baseTs === null) return "";
const diff = ms - baseTs;
if (diff < 0) return "0s";
const totalSec = Math.floor(diff / 1000);
if (totalSec < 60) return `${totalSec}s`;
const m = Math.floor(totalSec / 60);
const s = totalSec % 60;
return `${m}m${String(s).padStart(2, "0")}s`;
}
// Base timestamp per destination (first log line)
let baseTimes = $derived.by(() => {
const bt = {};
for (const [dest, lines] of Object.entries(destinations)) {
for (const line of lines) {
if (line.timestamp) {
bt[dest] = parseTs(line.timestamp);
break;
}
}
}
return bt;
});
let activeBaseTime = $derived(activeTab ? baseTimes[activeTab] ?? null : null);
function formatWallClock(ts) {
const ms = parseTs(ts);
if (ms === null) return "";
const d = new Date(ms);
const h = String(d.getHours()).padStart(2, "0");
const m = String(d.getMinutes()).padStart(2, "0");
const s = String(d.getSeconds()).padStart(2, "0");
const frac = String(d.getMilliseconds()).padStart(3, "0");
return `${h}:${m}:${s}.${frac}`;
}
</script>
<div class="logs-root" class:expanded>
{#if destNames.length === 0 && !done}
<div class="logs-empty">
{#if connected}
<span class="logs-dot"></span> Waiting for logs…
{:else}
No logs available
{/if}
</div>
{:else if destNames.length === 0 && done}
<div class="logs-empty">No logs recorded for this release.</div>
{:else}
<!-- Header: tabs + controls -->
<div class="logs-header">
<div class="logs-tabs">
{#each destNames as dest}
<button
class="logs-tab"
class:active={activeTab === dest}
onclick={() => (activeTab = dest)}
>
{dest}
<span class="logs-count">{destinations[dest]?.length || 0}</span>
</button>
{/each}
</div>
<div class="logs-controls">
{#if connected && !done}
<span class="logs-live">
<span class="logs-dot"></span> Live
</span>
{/if}
<button
class="logs-ctrl-btn"
class:active={showTimestamps}
onclick={() => (showTimestamps = !showTimestamps)}
title="Toggle timestamps"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
</button>
<button
class="logs-ctrl-btn"
onclick={() => (expanded = !expanded)}
title={expanded ? "Collapse" : "Expand"}
>
{#if expanded}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>
{/if}
</button>
</div>
</div>
<!-- Log output -->
<div class="logs-output" bind:this={logContainer} onscroll={handleScroll}>
{#each activeLines as entry, i}
<div
class="logs-line"
class:stderr={entry.channel === "stderr"}
class:status-line={entry.channel === "status"}
>
{#if showTimestamps}
<span class="logs-ts" title={formatWallClock(entry.timestamp)}>{formatElapsed(entry.timestamp, activeBaseTime)}</span>
{/if}
<span class="logs-text">{entry.line}</span>
</div>
{/each}
</div>
{#if !autoScroll}
<button class="logs-scroll-btn" onclick={scrollToBottom}>
↓ Scroll to bottom
</button>
{/if}
{/if}
</div>
<style>
.logs-root {
position: relative;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
overflow: hidden;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.8125rem;
line-height: 1.625;
background: #111827;
color: #d1d5db;
}
.logs-empty {
padding: 2rem;
text-align: center;
color: #6b7280;
font-family: system-ui, -apple-system, sans-serif;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.logs-header {
display: flex;
align-items: center;
background: #1f2937;
border-bottom: 1px solid #374151;
}
.logs-tabs {
display: flex;
gap: 0;
overflow-x: auto;
flex: 1;
min-width: 0;
}
.logs-tab {
padding: 0.5rem 1rem;
font-size: 0.75rem;
font-family: system-ui, -apple-system, sans-serif;
color: #9ca3af;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.375rem;
transition: color 0.15s, border-color 0.15s;
}
.logs-tab:hover {
color: #e5e7eb;
}
.logs-tab.active {
color: #f9fafb;
border-bottom-color: #3b82f6;
}
.logs-count {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
background: #374151;
color: #9ca3af;
}
.logs-controls {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0 0.5rem;
flex-shrink: 0;
}
.logs-ctrl-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 0.25rem;
border: none;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: color 0.15s, background 0.15s;
}
.logs-ctrl-btn:hover {
color: #d1d5db;
background: #374151;
}
.logs-ctrl-btn.active {
color: #93c5fd;
background: #1e3a5f;
}
.logs-live {
display: flex;
align-items: center;
gap: 0.375rem;
font-family: system-ui, -apple-system, sans-serif;
font-size: 0.6875rem;
color: #34d399;
text-transform: uppercase;
letter-spacing: 0.05em;
padding-right: 0.5rem;
}
.logs-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: #34d399;
display: inline-block;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.logs-output {
max-height: 60vh;
overflow-y: auto;
padding: 0.25rem 0;
}
.logs-root.expanded .logs-output {
max-height: 85vh;
}
.logs-output::-webkit-scrollbar {
width: 0.5rem;
}
.logs-output::-webkit-scrollbar-track {
background: #1f2937;
}
.logs-output::-webkit-scrollbar-thumb {
background: #4b5563;
border-radius: 0.25rem;
}
.logs-line {
display: flex;
padding: 0 1rem 0 0;
gap: 0;
min-height: 1.5rem;
}
.logs-line:hover {
background: rgba(255, 255, 255, 0.04);
}
.logs-line.stderr {
color: #fca5a5;
background: rgba(239, 68, 68, 0.06);
}
.logs-line.stderr:hover {
background: rgba(239, 68, 68, 0.1);
}
.logs-line.status-line {
color: #93c5fd;
font-weight: 600;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
border-top: 1px solid #1e3a5f;
margin-top: 0.25rem;
}
.logs-ts {
color: #4b5563;
white-space: nowrap;
user-select: none;
flex-shrink: 0;
width: 3.5rem;
text-align: right;
padding-right: 1rem;
padding-left: 0.75rem;
border-right: 1px solid #1f2937;
margin-right: 0.75rem;
}
.logs-text {
white-space: pre-wrap;
word-break: break-all;
flex: 1;
min-width: 0;
padding-left: 1rem;
}
.logs-line .logs-ts + .logs-text {
padding-left: 0;
}
.logs-scroll-btn {
position: absolute;
bottom: 0.75rem;
left: 50%;
transform: translateX(-50%);
padding: 0.25rem 0.75rem;
font-size: 0.6875rem;
font-family: system-ui, -apple-system, sans-serif;
color: #d1d5db;
background: #374151;
border: 1px solid #4b5563;
border-radius: 9999px;
cursor: pointer;
opacity: 0.9;
transition: opacity 0.15s;
}
.logs-scroll-btn:hover {
opacity: 1;
background: #4b5563;
}
</style>

View File

@@ -0,0 +1,670 @@
<svelte:options customElement={{ tag: "release-timeline", shadow: "none" }} />
<script>
import { onMount, onDestroy, tick } from "svelte";
import { fetchTimeline, connectSSE, formatElapsed, timeAgo } from "./lib/api.js";
import { envColors, envLaneColor, envBadgeClasses, statusDotColor } from "./lib/colors.js";
import { pipelineSummary, deployStageLabel, waitStageLabel, STATUS_CONFIG } from "./lib/status.js";
// Props from attributes
export let org = "";
export let project = "";
// Reactive state
let timeline = [];
let lanes = [];
let initialLoading = true; // only true until first successful load
let error = null;
let disconnectSSE = null;
let now = Date.now();
let timerInterval = null;
// DOM refs for swim lane positioning
let timelineEl = null;
let laneBarData = {};
const BAR_WIDTH = 20;
const BAR_GAP = 4;
const DOT_SIZE = 12;
const IN_FLIGHT = new Set(["QUEUED", "RUNNING", "ASSIGNED"]);
const DEPLOYED = new Set(["SUCCEEDED"]);
// ── Data fetching ────────────────────────────────────────────────
// Debounce re-fetches: multiple SSE events within 300ms only trigger one fetch
let refetchTimer = null;
function scheduleRefetch() {
if (refetchTimer) return; // already scheduled
refetchTimer = setTimeout(() => {
refetchTimer = null;
refreshData();
}, 300);
}
async function loadData() {
try {
error = null;
const data = await fetchTimeline(org, project);
applyTimelineData(data.timeline, data.lanes);
initialLoading = false;
scheduleComputeLaneBars();
} catch (e) {
error = e.message;
initialLoading = false;
}
}
// Background refresh: merge new data without loading state
async function refreshData() {
try {
const data = await fetchTimeline(org, project);
applyTimelineData(data.timeline, data.lanes);
scheduleComputeLaneBars();
} catch (e) {
// Silently ignore refresh failures — we still have the old data
console.warn("[release-timeline] refresh failed:", e);
}
}
// Merge new timeline data, preserving object identity where possible
// to minimize DOM thrash. Uses slug as the stable key.
function applyTimelineData(newTimeline, newLanes) {
// Build a map of existing releases by slug for fast lookup
const existingBySlug = new Map();
for (const item of timeline) {
if (item.kind === "release" && item.release) {
existingBySlug.set(item.release.slug, item);
}
}
// Merge: reuse existing objects when data hasn't changed
const merged = newTimeline.map(newItem => {
if (newItem.kind !== "release" || !newItem.release) return newItem;
const existing = existingBySlug.get(newItem.release.slug);
if (!existing) return newItem;
// Shallow-compare key fields; if same, keep the old reference
const oldR = existing.release;
const newR = newItem.release;
if (oldR.dest_envs === newR.dest_envs &&
oldR.has_pipeline === newR.has_pipeline &&
pipelineStagesEqual(oldR.pipeline_stages, newR.pipeline_stages) &&
destinationsEqual(oldR.destinations, newR.destinations)) {
return existing; // same reference = no DOM update
}
return newItem;
});
timeline = merged;
lanes = newLanes;
}
function pipelineStagesEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].status !== b[i].status || a[i].started_at !== b[i].started_at || a[i].completed_at !== b[i].completed_at) return false;
}
return true;
}
function destinationsEqual(a, b) {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
if (a[i].status !== b[i].status || a[i].completed_at !== b[i].completed_at) return false;
}
return true;
}
// ── SSE event handling ───────────────────────────────────────────
function handleEvent(type, data) {
if (type === "destination" && data.action === "status_changed") {
handleDestinationUpdate(data);
} else if (type === "release") {
if (data.action === "created") {
scheduleRefetch();
} else if (data.action === "status_changed" || data.action === "updated") {
handleReleaseUpdate(data);
}
} else if (type === "artifact" && (data.action === "created" || data.action === "updated")) {
scheduleRefetch();
} else if (type === "pipeline") {
handlePipelineUpdate(data);
}
}
function handleDestinationUpdate(data) {
const status = data.metadata?.status;
const destName = data.metadata?.destination_name || data.resource_id;
const env = data.metadata?.environment;
if (!status || !destName) return;
let changed = false;
timeline = timeline.map(item => {
if (item.kind !== "release" || !item.release) return item;
const r = item.release;
// Check if this release has a matching destination
const destIdx = r.destinations.findIndex(d => d.name === destName);
if (destIdx === -1) return item; // no match, keep same reference
changed = true;
const newDests = r.destinations.map(d =>
d.name === destName ? { ...d, status, ...(["SUCCEEDED","FAILED","TIMED_OUT","CANCELLED"].includes(status) ? { completed_at: new Date().toISOString() } : {}) } : d
);
const newEnvStatuses = newDests.map(d => `${d.environment}:${d.status || "PENDING"}`).join(",");
const newStages = env ? r.pipeline_stages.map(s =>
s.stage_type === "deploy" && s.environment === env ? { ...s, status: status === "ASSIGNED" ? "RUNNING" : status } : s
) : r.pipeline_stages;
return {
...item,
release: { ...r, destinations: newDests, dest_envs: newEnvStatuses, pipeline_stages: newStages }
};
});
if (changed) scheduleComputeLaneBars();
}
function handleReleaseUpdate(data) {
const status = data.metadata?.status;
const env = data.metadata?.environment;
if (status && env) {
handleDestinationUpdate(data);
} else {
scheduleRefetch();
}
}
function handlePipelineUpdate(data) {
const stageStatus = data.metadata?.status;
const stageEnv = data.metadata?.environment;
const stageType = data.metadata?.stage_type;
if (!stageStatus) {
if (data.action === "created" || data.action === "updated") scheduleRefetch();
return;
}
let changed = false;
timeline = timeline.map(item => {
if (item.kind !== "release" || !item.release) return item;
const r = item.release;
let stageChanged = false;
const newStages = r.pipeline_stages.map(s => {
if (stageEnv && s.stage_type === "deploy" && s.environment === stageEnv) {
stageChanged = true;
return { ...s, status: stageStatus, ...(s.started_at ? {} : { started_at: new Date().toISOString() }) };
}
if (stageType === "wait" && s.stage_type === "wait") {
stageChanged = true;
return { ...s, status: stageStatus };
}
return s;
});
if (!stageChanged) return item; // keep same reference
changed = true;
return { ...item, release: { ...r, pipeline_stages: newStages } };
});
if (changed) scheduleComputeLaneBars();
}
// ── Swim lane bar computation ────────────────────────────────────
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) };
});
}
// Debounce lane bar computation to one per frame
let laneBarRaf = null;
function scheduleComputeLaneBars() {
if (laneBarRaf) return;
laneBarRaf = requestAnimationFrame(() => {
laneBarRaf = null;
tick().then(computeLaneBars);
});
}
function computeLaneBars() {
if (!timelineEl) return;
const timelineRect = timelineEl.getBoundingClientRect();
if (timelineRect.height === 0) return;
const timelineH = timelineRect.height;
const cards = Array.from(timelineEl.querySelectorAll("[data-release]"));
const newBarData = {};
for (const lane of lanes) {
const env = lane.name;
let deployedCard = null, flightCard = null;
let deployedIdx = -1, 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 deployedTop = deployedCard ? deployedCard.getBoundingClientRect().top - timelineRect.top : null;
const flightTop = flightCard ? flightCard.getBoundingClientRect().top - timelineRect.top : null;
let solidH = 0;
if (deployedTop !== null && flightTop !== null) {
solidH = timelineH - Math.max(deployedTop, flightTop);
} else if (deployedTop !== null) {
solidH = timelineH - deployedTop;
}
const hasHatch = !!flightCard;
let hatchTop = 0, hatchH = 0, isForward = false;
if (flightCard) {
isForward = deployedIdx === -1 || flightIdx < deployedIdx;
const anchorY = deployedTop !== null ? deployedTop : timelineH;
const topY = Math.min(anchorY, flightTop);
const bottomY = Math.max(anchorY, flightTop);
hatchTop = topY;
hatchH = Math.max(bottomY - topY, 4);
}
const dots = [];
for (const card of cards) {
const entries = parseEnvs(card.dataset.envs);
if (!entries.find(e => e.env === env)) continue;
const avatar = card.querySelector("[data-avatar]");
const anchor = avatar || card;
const r = anchor.getBoundingClientRect();
dots.push(r.top + r.height / 2 - timelineRect.top);
}
newBarData[env] = { solidH, hasHatch, hatchTop, hatchH, isForward, dots, color: envColors(env) };
}
laneBarData = newBarData;
}
// ── Hatch pattern SVG ────────────────────────────────────────────
// Cache hatch pattern data URIs to avoid re-encoding on every render
const hatchCache = new Map();
function hatchPattern(color, bgColor) {
const key = `${color}|${bgColor}`;
let cached = hatchCache.get(key);
if (cached) return cached;
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>`;
cached = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
hatchCache.set(key, cached);
return cached;
}
// ── Lifecycle ────────────────────────────────────────────────────
onMount(() => {
loadData();
// Update "time ago" labels every 10 seconds instead of every 1 second
// — 1s resolution adds no value for "3m ago" style labels
timerInterval = setInterval(() => { now = Date.now(); }, 10000);
});
onDestroy(() => {
if (disconnectSSE) disconnectSSE();
if (timerInterval) clearInterval(timerInterval);
if (refetchTimer) clearTimeout(refetchTimer);
if (laneBarRaf) cancelAnimationFrame(laneBarRaf);
});
// Connect SSE after first data load
$: if (!initialLoading && !error && org && !disconnectSSE) {
disconnectSSE = connectSSE(org, project, handleEvent);
}
// Recompute lane bars on window resize (debounced via rAF)
function handleResize() { scheduleComputeLaneBars(); }
// ── Helpers for template ─────────────────────────────────────────
function elapsedStr(startedAt, completedAt, status) {
if (!startedAt) return "";
const start = new Date(startedAt).getTime();
if (isNaN(start)) return "";
if (completedAt && status !== "RUNNING" && status !== "QUEUED") {
const end = new Date(completedAt).getTime();
if (!isNaN(end)) return formatElapsed(Math.floor((end - start) / 1000));
}
return formatElapsed(Math.floor((now - start) / 1000));
}
// Unique key for each timeline item (used in keyed {#each})
function itemKey(item) {
if (item.kind === "release" && item.release) return `r:${item.release.slug}`;
if (item.kind === "hidden") return `h:${item.count}:${(item.releases || [])[0]?.slug || ""}`;
return `u:${Math.random()}`;
}
// Which deploy stages to show as badges on the summary line,
// filtered to match the current pipeline state.
function summaryShowsStage(summary, stageStatus) {
if (!summary) return false;
switch (summary.label) {
case "Pipeline complete": return stageStatus === "SUCCEEDED";
case "Pipeline failed": return stageStatus === "FAILED" || stageStatus === "RUNNING" || stageStatus === "ASSIGNED";
case "Deploying to": return stageStatus === "RUNNING" || stageStatus === "ASSIGNED";
case "Queued": return stageStatus === "QUEUED";
case "Waiting for time window": return stageStatus === "RUNNING" || stageStatus === "ASSIGNED";
default: return stageStatus !== "PENDING" && stageStatus !== "SUCCEEDED";
}
}
$: laneCount = lanes.length;
$: gutterWidth = laneCount * (BAR_WIDTH + BAR_GAP) + 8;
</script>
<svelte:window on:resize={handleResize} />
{#if initialLoading}
<div class="max-w-5xl mx-auto p-12 text-center text-gray-400">
<span class="w-5 h-5 inline-block border-2 border-gray-300 border-t-gray-600 rounded-full animate-spin"></span>
<p class="mt-2 text-sm">Loading releases...</p>
</div>
{:else if error}
<div class="max-w-5xl mx-auto p-6 border border-red-200 rounded-lg text-center">
<p class="text-red-600">{error}</p>
<button class="mt-2 text-sm text-gray-500 hover:text-gray-900 underline" on:click={loadData}>Retry</button>
</div>
{:else if timeline.length === 0}
<div class="max-w-5xl mx-auto p-6 border border-gray-200 rounded-lg text-center">
<p class="text-gray-600">No releases yet.</p>
<p class="text-sm text-gray-400 mt-2">Create a release with <code class="bg-gray-100 px-1 rounded">forest release create</code></p>
</div>
{:else}
<div class="max-w-5xl mx-auto grid" style="grid-template-columns: {gutterWidth}px 1fr; grid-template-rows: 1fr auto;">
<!-- Swim lane gutter -->
<div class="flex" style="grid-row: 1;">
{#each lanes as lane (lane.name)}
{@const bar = laneBarData[lane.name]}
{@const [barColor, lightColor] = bar?.color || [lane.color, "#e5e7eb"]}
<div style="width: {BAR_WIDTH}px; margin-right: {BAR_GAP}px; position: relative;">
{#if bar}
{#if bar.hasHatch}
<div class="lane-bar lane-pulse" style="position: absolute; left: 0; width: 100%; top: {bar.hatchTop}px; height: {bar.hatchH + (bar.solidH > 0 ? BAR_WIDTH / 2 : 0)}px; background-image: {bar.isForward ? hatchPattern(barColor, lightColor) : hatchPattern('#f59e0b', '#fef3c7')}; background-size: 8px 8px; background-repeat: repeat; border-radius: 9999px; z-index: 0;"></div>
{/if}
{#if bar.solidH > 0}
<div class="lane-bar" style="position: absolute; bottom: 0; left: 0; width: 100%; height: {bar.solidH + (bar.hasHatch ? BAR_WIDTH / 2 : 0)}px; background: {barColor}; border-radius: 9999px; z-index: 1;"></div>
{/if}
{#each bar.dots as dotY, di (di)}
<div class="lane-dot" style="position: absolute; left: 50%; transform: translateX(-50%); top: {dotY - DOT_SIZE/2}px; width: {DOT_SIZE}px; height: {DOT_SIZE}px; border-radius: 50%; background: #fff; border: 2px solid {barColor}; z-index: 2;"></div>
{/each}
{/if}
</div>
{/each}
</div>
<!-- Timeline cards -->
<div bind:this={timelineEl} class="space-y-3 min-w-0" style="grid-row: 1;">
{#each timeline as item (itemKey(item))}
{#if item.kind === "release" && item.release}
{@const release = item.release}
<div data-release data-envs={release.dest_envs} class="border border-gray-200 rounded-lg overflow-hidden">
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="inline-block w-6 h-6 rounded-full bg-gray-200 shrink-0" data-avatar></span>
<a href="/orgs/{org}/projects/{release.project_name || project}/releases/{release.slug}" class="font-medium text-gray-900 hover:text-black truncate">
{release.title}
</a>
</div>
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0 flex-wrap">
{#if release.branch}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z"/></svg>
{release.branch}
</span>
{/if}
{#if release.commit_sha}
<span class="font-mono">{release.commit_sha.slice(0, 7)}</span>
{/if}
<time>{timeAgo(release.created_at)}</time>
{#if release.source_user}
<span class="flex items-center gap-1">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
<a href="/users/{release.source_user}" class="hover:underline">{release.source_user}</a>
</span>
{/if}
{#if release.project_name && release.project_name !== project}
<a href="/orgs/{org}/projects/{release.project_name}" class="hover:underline">{release.project_name}</a>
{/if}
</div>
</div>
<!-- Summary + details -->
<details class="border-t border-gray-100 group" on:toggle={scheduleComputeLaneBars}>
<summary class="px-4 py-2 flex items-center gap-2 text-sm cursor-pointer list-none hover:bg-gray-50 flex-wrap">
{#if release.has_pipeline && !pipelineSummary(release.pipeline_stages)}
<!-- Pipeline exists but not triggered yet -->
{@const envAllDone = release.env_groups && release.env_groups.length > 0 && release.env_groups.every(g => g.status === "SUCCEEDED")}
<svg class="w-3.5 h-3.5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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"/></svg>
{#if envAllDone}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-gray-500 text-sm">Deployed</span>
{:else}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-blue-600 text-sm">Queued</span>
{/if}
{:else if release.has_pipeline && pipelineSummary(release.pipeline_stages)}
{@const summary = pipelineSummary(release.pipeline_stages)}
<svg class="w-3.5 h-3.5 text-purple-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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"/></svg>
{#if summary.icon === "pulse"}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{:else if summary.icon === "check-circle"}
<svg class="w-4 h-4 {summary.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if summary.icon === "x-circle"}
<svg class="w-4 h-4 {summary.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if summary.icon === "clock"}
<svg class="w-4 h-4 {summary.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
{/if}
<span class="{summary.color} text-sm">{summary.label}</span>
{#each release.pipeline_stages as stage (stage.id || stage.environment || stage.stage_type)}
{#if stage.stage_type === "deploy" && summaryShowsStage(summary, stage.status)}
{@const badge = envBadgeClasses(stage.environment || "")}
{@const dot = statusDotColor(stage.status) || badge.dot}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {badge.bg}">
{stage.environment}
<span class="w-1.5 h-1.5 rounded-full {dot}"></span>
</span>
{/if}
{/each}
<span class="text-xs text-gray-400">{summary.done}/{summary.total}</span>
{:else if release.env_groups && release.env_groups.length > 0}
{@const allSucceeded = release.env_groups.every(g => g.status === "SUCCEEDED")}
{#if allSucceeded}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-gray-500 text-sm">Deployed</span>
{:else}
{#each release.env_groups as group, gi (gi)}
{#if group.status !== "SUCCEEDED"}
{@const cfg = STATUS_CONFIG[group.status] || STATUS_CONFIG.SUCCEEDED}
{#if cfg.icon === "pulse"}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{:else if cfg.icon === "check-circle"}
<svg class="w-4 h-4 {cfg.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else}
<svg class="w-4 h-4 {cfg.iconColor} shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{/if}
<span class="{cfg.color} text-sm">{cfg.label}</span>
{#each group.envs as env (env)}
{@const badge = envBadgeClasses(env)}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {badge.bg}">
{env}
<span class="w-1.5 h-1.5 rounded-full {badge.dot}"></span>
</span>
{/each}
{/if}
{/each}
{/if}
{:else}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
<span class="text-gray-400 text-sm">Pending</span>
{/if}
<svg class="w-3 h-3 text-gray-400 shrink-0 ml-auto transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</summary>
<!-- Release details -->
<div class="px-4 py-3 border-t border-gray-100 space-y-3">
{#if release.description}
<p class="text-sm text-gray-700">{release.description}</p>
{/if}
<div class="flex flex-wrap gap-x-6 gap-y-2 text-xs text-gray-500">
<span class="font-mono text-gray-400">{release.slug}</span>
{#if release.version}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">{release.version}</span>
{/if}
</div>
</div>
<!-- Pipeline stages -->
{#if release.has_pipeline}
<div class="border-t border-gray-100">
{#each release.pipeline_stages as stage, i (stage.id || `${stage.stage_type}-${stage.environment}-${i}`)}
<div class="px-4 py-2.5 flex items-center gap-3 text-sm {i < release.pipeline_stages.length - 1 ? 'border-b border-gray-50' : ''} {stage.status === 'PENDING' ? 'opacity-50' : ''}">
{#if stage.status === "SUCCEEDED"}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if stage.status === "RUNNING"}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{:else if stage.status === "QUEUED"}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if stage.status === "FAILED"}
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" stroke-width="2"/></svg>
{/if}
{#if stage.stage_type === "deploy"}
<span class="text-sm {stage.status === 'SUCCEEDED' ? 'text-gray-700' : stage.status === 'RUNNING' ? 'text-yellow-700' : stage.status === 'FAILED' ? 'text-red-700' : 'text-gray-400'}">
{deployStageLabel(stage.status)}
</span>
{@const badge = envBadgeClasses(stage.environment || "")}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {badge.bg}">
{stage.environment}
<span class="w-1.5 h-1.5 rounded-full {badge.dot}"></span>
</span>
{:else if stage.stage_type === "wait"}
<span class="text-sm {stage.status === 'SUCCEEDED' ? 'text-gray-700' : stage.status === 'RUNNING' ? 'text-yellow-700' : 'text-gray-400'}">
{waitStageLabel(stage.status)} {stage.duration_seconds}s
</span>
{/if}
{#if stage.started_at && (stage.status === "RUNNING" || stage.status === "QUEUED" || stage.completed_at)}
<span class="text-xs text-gray-400 tabular-nums">{elapsedStr(stage.started_at, stage.completed_at, stage.status)}</span>
{/if}
<span class="ml-auto flex items-center gap-1 text-xs text-gray-400 shrink-0">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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"/></svg>
pipeline
</span>
</div>
{/each}
</div>
{/if}
<!-- Destinations -->
{#each release.destinations as dest, i (dest.name)}
{@const destBadge = envBadgeClasses(dest.environment || "")}
<div class="px-4 py-2 flex items-center gap-3 text-sm {i < release.destinations.length - 1 ? 'border-b border-gray-50' : ''} border-t border-gray-100">
{#if dest.status === "SUCCEEDED"}
<svg class="w-4 h-4 text-green-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if dest.status === "RUNNING" || dest.status === "ASSIGNED"}
<span class="w-4 h-4 shrink-0 flex items-center justify-center"><span class="w-2.5 h-2.5 rounded-full bg-yellow-500 animate-pulse"></span></span>
{:else if dest.status === "QUEUED"}
<svg class="w-4 h-4 text-blue-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else if dest.status === "FAILED"}
<svg class="w-4 h-4 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{:else}
<svg class="w-4 h-4 text-gray-300 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
{/if}
<span class="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full {destBadge.bg}">
{dest.environment}
<span class="w-1.5 h-1.5 rounded-full {destBadge.dot}"></span>
</span>
<span class="text-gray-400 text-xs">{dest.name}</span>
{#if dest.status === "SUCCEEDED"}
<span class="text-xs text-green-600">Deployed</span>
{:else if dest.status === "RUNNING"}
<span class="text-xs text-yellow-600">Deploying</span>
{:else if dest.status === "QUEUED"}
<span class="text-xs text-blue-600">Queued{dest.queue_position ? ` #${dest.queue_position}` : ""}</span>
{:else if dest.status === "FAILED"}
<span class="text-xs text-red-600">Failed</span>
{/if}
{#if dest.completed_at}
<time class="text-xs text-gray-400 ml-auto">{timeAgo(dest.completed_at)}</time>
{/if}
</div>
{/each}
</details>
</div>
{:else if item.kind === "hidden"}
<details class="group" on:toggle={scheduleComputeLaneBars}>
<summary class="flex items-center gap-2 py-2 px-1 text-sm text-gray-400 cursor-pointer hover:text-gray-600 list-none">
<svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
{item.count} hidden commit{item.count !== 1 ? "s" : ""}
<span class="text-gray-300">&middot;</span>
<span class="group-open:hidden">Show commit{item.count !== 1 ? "s" : ""}</span>
<span class="hidden group-open:inline">Hide commit{item.count !== 1 ? "s" : ""}</span>
</summary>
<div class="space-y-3 mt-1">
{#each item.releases || [] as release (release.slug)}
<div data-release data-envs="" class="border border-gray-200 rounded-lg overflow-hidden opacity-75">
<div class="px-4 py-3 flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-2 min-w-0 flex-1">
<span class="inline-block w-6 h-6 rounded-full bg-gray-200 shrink-0" data-avatar></span>
<a href="/orgs/{org}/projects/{release.project_name || project}/releases/{release.slug}" class="font-medium text-gray-900 hover:text-black truncate">
{release.title}
</a>
</div>
<div class="flex items-center gap-4 text-xs text-gray-500 shrink-0">
{#if release.commit_sha}
<span class="font-mono">{release.commit_sha.slice(0, 7)}</span>
{/if}
<time>{timeAgo(release.created_at)}</time>
</div>
</div>
</div>
{/each}
</div>
</details>
{/if}
{/each}
</div>
<!-- Lane labels (row 2, column 1) -->
<div class="flex pt-1" style="grid-row: 2; grid-column: 1; height: 56px;">
{#each lanes as lane (lane.name)}
<div style="width: {BAR_WIDTH}px; margin-right: {BAR_GAP}px; display: flex; justify-content: center;">
<span style="writing-mode: vertical-rl; transform: rotate(180deg); font-size: 10px; font-weight: 500; color: {lane.color}; white-space: nowrap;">{lane.name}</span>
</div>
{/each}
</div>
</div>
{/if}
<style>
@keyframes lane-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
:global(.lane-pulse) {
animation: lane-pulse 2s ease-in-out infinite;
}
</style>

96
frontend/src/lib/api.js Normal file
View File

@@ -0,0 +1,96 @@
/**
* Fetch timeline data from the JSON API.
* @param {string} org
* @param {string} project
* @returns {Promise<{timeline: Array, lanes: Array}>}
*/
export async function fetchTimeline(org, project) {
const url = project
? `/api/orgs/${org}/projects/${project}/timeline`
: `/api/orgs/${org}/timeline`;
const res = await fetch(url, {
credentials: "same-origin",
});
if (!res.ok) throw new Error(`Timeline fetch failed: ${res.status}`);
return res.json();
}
/**
* Connect to SSE endpoint for live updates.
* Returns a disconnect function.
* @param {string} org
* @param {string} project
* @param {(type: string, data: object) => void} onEvent
* @returns {() => void} disconnect
*/
export function connectSSE(org, project, onEvent) {
const url = project
? `/orgs/${org}/projects/${project}/events`
: `/orgs/${org}/events`;
let retryDelay = 1000;
let es = null;
let stopped = false;
function connect() {
if (stopped) return;
es = new EventSource(url);
es.addEventListener("open", () => {
retryDelay = 1000;
});
for (const type of ["destination", "release", "artifact", "pipeline"]) {
es.addEventListener(type, (e) => {
try {
const data = JSON.parse(e.data);
onEvent(type, data);
} catch (err) {
console.warn(`[release-timeline] bad ${type} event:`, err);
}
});
}
es.addEventListener("error", () => {
es.close();
if (!stopped) {
setTimeout(connect, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000);
}
});
}
connect();
return () => {
stopped = true;
if (es) es.close();
};
}
/**
* Format elapsed time from seconds.
*/
export 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`;
}
/**
* Format a relative timestamp.
*/
export function timeAgo(dateStr) {
if (!dateStr) return "";
const date = new Date(dateStr);
const now = Date.now();
const diff = Math.floor((now - date.getTime()) / 1000);
if (diff < 10) return "just now";
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}

View File

@@ -0,0 +1,53 @@
/** Environment-to-color mapping (matches swim-lanes.js + platform.rs) */
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"];
export 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;
}
export function envLaneColor(name) {
return envColors(name)[0];
}
export function envBadgeClasses(env) {
const lower = env.toLowerCase();
if (lower.includes("prod") && !lower.includes("preprod") && !lower.includes("pre-prod")) {
return { bg: "bg-pink-100 text-pink-800", dot: "bg-pink-500" };
}
if (lower.includes("preprod") || lower.includes("pre-prod")) {
return { bg: "bg-orange-100 text-orange-800", dot: "bg-orange-500" };
}
if (lower.includes("stag")) {
return { bg: "bg-yellow-100 text-yellow-800", dot: "bg-yellow-500" };
}
if (lower.includes("dev")) {
return { bg: "bg-violet-100 text-violet-800", dot: "bg-violet-500" };
}
return { bg: "bg-gray-100 text-gray-700", dot: "bg-gray-400" };
}
export function statusDotColor(status) {
switch (status) {
case "SUCCEEDED": return "bg-green-500";
case "RUNNING": return "bg-yellow-500";
case "FAILED": return "bg-red-500";
default: return null;
}
}

View File

@@ -0,0 +1,63 @@
/** Status display configuration — matches live-events.js */
export const STATUS_CONFIG = {
SUCCEEDED: { label: "Deployed to", stageLabel: "Deployed to", color: "text-green-600", icon: "check-circle", iconColor: "text-green-500" },
RUNNING: { label: "Deploying to", stageLabel: "Deploying to", color: "text-yellow-700", icon: "pulse", iconColor: "text-yellow-500" },
ASSIGNED: { label: "Deploying to", stageLabel: "Deploying to", color: "text-yellow-700", icon: "pulse", iconColor: "text-yellow-500" },
QUEUED: { label: "Queued for", stageLabel: "Queued for", color: "text-blue-600", icon: "clock", iconColor: "text-blue-400" },
FAILED: { label: "Failed on", stageLabel: "Failed on", color: "text-red-600", icon: "x-circle", iconColor: "text-red-500" },
TIMED_OUT: { label: "Timed out on", stageLabel: "Timed out on", color: "text-orange-600", icon: "clock", iconColor: "text-orange-500" },
CANCELLED: { label: "Cancelled", stageLabel: "Cancelled", color: "text-gray-500", icon: "ban", iconColor: "text-gray-400" },
};
export function pipelineSummary(stages) {
if (!stages || stages.length === 0) return null;
let allDone = true, anyFailed = false, anyRunning = false, anyWaiting = false, anyQueued = false;
let done = 0;
const total = stages.length;
for (const s of stages) {
if (s.status === "SUCCEEDED") done++;
if (s.status !== "SUCCEEDED") allDone = false;
if (s.status === "FAILED") anyFailed = true;
if (s.status === "RUNNING") anyRunning = true;
if (s.status === "QUEUED") anyQueued = true;
if (s.stage_type === "wait" && s.status === "RUNNING") anyWaiting = true;
}
if (allDone) return { label: "Pipeline complete", color: "text-gray-600", icon: "check-circle", iconColor: "text-green-500", done, total };
if (anyFailed) return { label: "Pipeline failed", color: "text-red-600", icon: "x-circle", iconColor: "text-red-500", done, total };
if (anyWaiting) return { label: "Waiting for time window", color: "text-yellow-700", icon: "clock", iconColor: "text-yellow-500", done, total };
if (anyRunning) return { label: "Deploying to", color: "text-yellow-700", icon: "pulse", iconColor: "text-yellow-500", done, total };
if (anyQueued) return { label: "Queued", color: "text-blue-600", icon: "clock", iconColor: "text-blue-400", done, total };
return { label: "Pipeline pending", color: "text-gray-400", icon: "pending", iconColor: "text-gray-300", done, total };
}
export function envGroupSummary(envGroups) {
if (!envGroups || envGroups.length === 0) return null;
return envGroups.map(g => ({
...g,
config: STATUS_CONFIG[g.status] || STATUS_CONFIG.SUCCEEDED,
}));
}
export function waitStageLabel(status) {
switch (status) {
case "SUCCEEDED": return "Waited";
case "RUNNING": return "Waiting";
case "FAILED": return "Wait failed";
case "CANCELLED": return "Wait cancelled";
default: return "Wait";
}
}
export function deployStageLabel(status) {
switch (status) {
case "SUCCEEDED": return "Deployed to";
case "RUNNING": return "Deploying to";
case "QUEUED": return "Queued for";
case "FAILED": return "Failed on";
case "TIMED_OUT": return "Timed out on";
case "CANCELLED": return "Cancelled";
default: return "Deploy to";
}
}

3
frontend/src/main.js Normal file
View File

@@ -0,0 +1,3 @@
// Register all Svelte web components
import "./ReleaseTimeline.svelte";
import "./ReleaseLogs.svelte";