469
frontend/src/ReleaseLogs.svelte
Normal file
469
frontend/src/ReleaseLogs.svelte
Normal 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>
|
||||
670
frontend/src/ReleaseTimeline.svelte
Normal file
670
frontend/src/ReleaseTimeline.svelte
Normal 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">·</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
96
frontend/src/lib/api.js
Normal 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`;
|
||||
}
|
||||
53
frontend/src/lib/colors.js
Normal file
53
frontend/src/lib/colors.js
Normal 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;
|
||||
}
|
||||
}
|
||||
63
frontend/src/lib/status.js
Normal file
63
frontend/src/lib/status.js
Normal 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
3
frontend/src/main.js
Normal file
@@ -0,0 +1,3 @@
|
||||
// Register all Svelte web components
|
||||
import "./ReleaseTimeline.svelte";
|
||||
import "./ReleaseLogs.svelte";
|
||||
Reference in New Issue
Block a user