/** * web component * * Visual DAG builder for release pipeline stages. * Syncs to a hidden textarea (data-target) as JSON. * * Stage format (matches Rust serde of PipelineStage): * { "id": "stage-name", "depends_on": ["other"], "config": {"Deploy": {"environment": "prod"}} } * * Usage: * * */ class PipelineBuilder extends HTMLElement { connectedCallback() { this.stages = []; this._targetId = this.dataset.target; this._readonly = this.dataset.readonly === "true"; this._mode = "builder"; // "builder" | "json" // Load initial value from target textarea const target = this._target(); if (target && target.value.trim()) { try { const parsed = JSON.parse(target.value.trim()); this.stages = this._parseStages(parsed); } catch (e) { this._rawJson = target.value.trim(); } } this._render(); } _target() { return this._targetId ? document.getElementById(this._targetId) : null; } // Extract the stage type string from a config object _stageType(config) { if (!config) return "deploy"; if (config.Deploy !== undefined) return "deploy"; if (config.Wait !== undefined) return "wait"; return "deploy"; } // Extract display info from config _configLabel(config) { if (!config) return ""; if (config.Deploy) return config.Deploy.environment || ""; if (config.Wait) return config.Wait.duration_seconds ? `${config.Wait.duration_seconds}s` : ""; return ""; } _normalizeStage(s) { // Handle the new typed format: {id, depends_on, config: {Deploy: {environment}}} if (s.id !== undefined) { return { id: s.id || "", depends_on: Array.isArray(s.depends_on) ? s.depends_on : [], config: s.config || { Deploy: { environment: "" } }, }; } // Legacy format: {name, type, depends_on} const type = s.type || "deploy"; const config = type === "wait" ? { Wait: { duration_seconds: s.duration_seconds || 0 } } : { Deploy: { environment: s.environment || "" } }; return { id: s.name || "", depends_on: Array.isArray(s.depends_on) ? s.depends_on : [], config, }; } _parseStages(parsed) { if (Array.isArray(parsed)) { return parsed.map((s) => this._normalizeStage(s)); } if (parsed.stages && Array.isArray(parsed.stages)) { return parsed.stages.map((s) => this._normalizeStage(s)); } // Map format: { "id": { depends_on, config } } if (typeof parsed === "object" && parsed !== null) { return Object.entries(parsed).map(([id, val]) => this._normalizeStage({ id, ...val }) ); } return []; } _sync() { const target = this._target(); if (!target) return; if (this.stages.length === 0) { target.value = ""; return; } // Filter out stages with no id const valid = this.stages.filter((s) => s.id.trim()); target.value = JSON.stringify(valid, null, 2); } _validate() { const ids = this.stages.map((s) => s.id).filter(Boolean); const idSet = new Set(ids); const errors = []; if (ids.length !== idSet.size) { errors.push("Duplicate stage IDs detected"); } for (const s of this.stages) { for (const dep of s.depends_on) { if (!idSet.has(dep)) { errors.push(`"${s.id}" depends on unknown stage "${dep}"`); } } } // Cycle detection (Kahn's algorithm) const inDegree = {}; const adj = {}; for (const s of this.stages) { if (!s.id) continue; inDegree[s.id] = 0; adj[s.id] = []; } for (const s of this.stages) { if (!s.id) continue; for (const dep of s.depends_on) { if (adj[dep]) { adj[dep].push(s.id); inDegree[s.id]++; } } } const queue = Object.keys(inDegree).filter((k) => inDegree[k] === 0); let visited = 0; while (queue.length > 0) { const node = queue.shift(); visited++; for (const next of adj[node] || []) { inDegree[next]--; if (inDegree[next] === 0) queue.push(next); } } if (visited < Object.keys(inDegree).length) { errors.push("Cycle detected in stage dependencies"); } for (let i = 0; i < this.stages.length; i++) { if (!this.stages[i].id.trim()) { errors.push(`Stage ${i + 1} has no ID`); } } return errors; } _computeLevels() { const byId = {}; for (const s of this.stages) { if (s.id) byId[s.id] = s; } const levels = {}; const visited = new Set(); const getLevel = (id) => { if (levels[id] !== undefined) return levels[id]; if (visited.has(id)) return 0; visited.add(id); const s = byId[id]; if (!s || s.depends_on.length === 0) { levels[id] = 0; return 0; } let maxDep = 0; for (const dep of s.depends_on) { if (byId[dep]) { maxDep = Math.max(maxDep, getLevel(dep) + 1); } } levels[id] = maxDep; return maxDep; }; for (const s of this.stages) { if (s.id) getLevel(s.id); } return levels; } _render() { const errors = this._validate(); if (!this._readonly) this._sync(); this.innerHTML = ""; this.className = "block"; // Readonly mode: just show the DAG if (this._readonly) { if (this.stages.length > 0) { const canvas = el("div", "dag-canvas overflow-x-auto"); this._renderDag(canvas); this.append(canvas); } else { this.append(el("p", "text-xs text-gray-400 italic", "No stages defined")); } return; } // Mode toggle const toolbar = el("div", "flex items-center gap-2 mb-3"); const builderBtn = el( "button", `text-xs px-2.5 py-1 rounded border ${this._mode === "builder" ? "bg-gray-900 text-white border-gray-900" : "border-gray-300 text-gray-600 hover:bg-gray-50"}`, "Builder" ); builderBtn.type = "button"; builderBtn.onclick = () => { if (this._mode === "json") { const ta = this.querySelector(".json-editor"); if (ta) { try { const parsed = JSON.parse(ta.value); this.stages = this._parseStages(parsed); this._rawJson = null; } catch (e) { this._rawJson = ta.value; } } this._mode = "builder"; this._render(); } }; const jsonBtn = el( "button", `text-xs px-2.5 py-1 rounded border ${this._mode === "json" ? "bg-gray-900 text-white border-gray-900" : "border-gray-300 text-gray-600 hover:bg-gray-50"}`, "JSON" ); jsonBtn.type = "button"; jsonBtn.onclick = () => { this._mode = "json"; this._render(); }; toolbar.append(builderBtn, jsonBtn); if (this._mode === "builder" && this.stages.length > 0) { const stageCount = el("span", "text-xs text-gray-400 ml-auto", `${this.stages.length} stage${this.stages.length !== 1 ? "s" : ""}`); toolbar.append(stageCount); } this.append(toolbar); if (this._mode === "json") { this._renderJsonMode(); } else { this._renderBuilderMode(errors); } } _renderJsonMode() { const target = this._target(); const currentJson = this._rawJson || (target ? target.value : "") || "[]"; const ta = el("textarea", "json-editor w-full border border-gray-300 rounded-md px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900 resize-y"); ta.rows = 12; ta.value = currentJson; ta.spellcheck = false; ta.oninput = () => { const t = this._target(); if (t) t.value = ta.value; this._updateJsonErrors(ta.value); }; const errBox = el("div", "json-errors mt-2"); this.append(ta, errBox); this._updateJsonErrors(currentJson); } _updateJsonErrors(value) { const errBox = this.querySelector(".json-errors"); if (!errBox) return; errBox.innerHTML = ""; if (!value.trim()) return; try { const parsed = JSON.parse(value); const stages = Array.isArray(parsed) ? parsed : (parsed.stages || []); const ids = stages.map((s) => s.id || s.name).filter(Boolean); if (new Set(ids).size !== ids.length) { errBox.append(el("p", "text-xs text-amber-600", "Warning: duplicate stage IDs")); } } catch (e) { errBox.append(el("p", "text-xs text-red-600", "Invalid JSON: " + e.message)); } } _renderBuilderMode(errors) { if (this.stages.length > 0) { const dagBox = el("div", "mb-4 border border-gray-200 rounded-lg overflow-hidden"); const canvas = el("div", "dag-canvas p-4 bg-gray-50 overflow-x-auto"); canvas.style.minHeight = "80px"; this._renderDag(canvas); dagBox.append(canvas); this.append(dagBox); } const list = el("div", "space-y-2 mb-3"); for (let i = 0; i < this.stages.length; i++) { list.append(this._renderStageCard(i)); } this.append(list); if (errors.length > 0) { const errBox = el("div", "mb-3 p-3 bg-red-50 border border-red-200 rounded-md"); for (const err of errors) { errBox.append(el("p", "text-xs text-red-700", err)); } this.append(errBox); } const addBtn = el("button", "text-sm px-3 py-1.5 rounded border border-dashed border-gray-300 text-gray-500 hover:border-gray-400 hover:text-gray-700 w-full", "+ Add stage"); addBtn.type = "button"; addBtn.onmousedown = (e) => e.preventDefault(); addBtn.onclick = () => { clearTimeout(this._blurTimer); this.stages.push({ id: "", depends_on: [], config: { Deploy: { environment: "" } } }); this._render(); requestAnimationFrame(() => { const inputs = this.querySelectorAll('input[data-field="id"]'); if (inputs.length) inputs[inputs.length - 1].focus(); }); }; this.append(addBtn); } _renderStageCard(index) { const stage = this.stages[index]; const type = this._stageType(stage.config); const otherIds = this.stages .map((s, i) => (i !== index && s.id.trim() ? s.id.trim() : null)) .filter(Boolean); const card = el("div", "border border-gray-200 rounded-md bg-white"); // Header row const header = el("div", "flex items-center gap-2 px-3 py-2"); const badge = el("span", "text-xs font-mono text-gray-400 w-5 shrink-0", `${index + 1}`); // ID input const idInput = el("input", "flex-1 border border-gray-200 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-gray-400 min-w-0"); idInput.type = "text"; idInput.value = stage.id; idInput.placeholder = "stage id"; idInput.dataset.field = "id"; idInput.oninput = () => { this.stages[index].id = idInput.value.trim().toLowerCase().replace(/[^a-z0-9_-]/g, "-"); idInput.value = this.stages[index].id; this._sync(); this._renderDagIfPresent(); }; idInput.onblur = () => { this._blurTimer = setTimeout(() => this._render(), 150); }; // Type select (deploy / wait) const typeSelect = el("select", "border border-gray-200 rounded px-2 py-1 text-xs bg-white shrink-0"); for (const t of ["deploy", "wait"]) { const opt = document.createElement("option"); opt.value = t; opt.textContent = t; opt.selected = type === t; typeSelect.append(opt); } typeSelect.onmousedown = (e) => e.stopPropagation(); typeSelect.onchange = () => { clearTimeout(this._blurTimer); if (typeSelect.value === "wait") { this.stages[index].config = { Wait: { duration_seconds: 0 } }; } else { this.stages[index].config = { Deploy: { environment: "" } }; } this._render(); }; // Remove button const removeBtn = el("button", "text-gray-400 hover:text-red-500 shrink-0 p-1"); removeBtn.type = "button"; removeBtn.innerHTML = ``; removeBtn.title = "Remove stage"; removeBtn.onmousedown = (e) => e.preventDefault(); removeBtn.onclick = () => { clearTimeout(this._blurTimer); const removedId = this.stages[index].id; this.stages.splice(index, 1); for (const s of this.stages) { s.depends_on = s.depends_on.filter((d) => d !== removedId); } this._render(); }; header.append(badge, idInput, typeSelect, removeBtn); card.append(header); // Config row (type-specific fields) const configRow = el("div", "px-3 pb-2 flex items-center gap-2 flex-wrap"); if (type === "deploy") { const envLabel = el("span", "text-xs text-gray-500 shrink-0", "env:"); const envInput = el("input", "border border-gray-200 rounded px-2 py-1 text-xs w-32 focus:outline-none focus:ring-1 focus:ring-gray-400"); envInput.type = "text"; envInput.value = (stage.config.Deploy && stage.config.Deploy.environment) || ""; envInput.placeholder = "environment"; envInput.onmousedown = (e) => e.stopPropagation(); envInput.oninput = () => { if (!this.stages[index].config.Deploy) this.stages[index].config = { Deploy: { environment: "" } }; this.stages[index].config.Deploy.environment = envInput.value.trim(); this._sync(); }; envInput.onblur = () => { this._blurTimer = setTimeout(() => this._render(), 150); }; configRow.append(envLabel, envInput); } else if (type === "wait") { const durLabel = el("span", "text-xs text-gray-500 shrink-0", "wait:"); const durInput = el("input", "border border-gray-200 rounded px-2 py-1 text-xs w-20 focus:outline-none focus:ring-1 focus:ring-gray-400"); durInput.type = "number"; durInput.min = "0"; durInput.value = (stage.config.Wait && stage.config.Wait.duration_seconds) || 0; durInput.placeholder = "seconds"; durInput.onmousedown = (e) => e.stopPropagation(); durInput.oninput = () => { if (!this.stages[index].config.Wait) this.stages[index].config = { Wait: { duration_seconds: 0 } }; this.stages[index].config.Wait.duration_seconds = parseInt(durInput.value) || 0; this._sync(); }; durInput.onblur = () => { this._blurTimer = setTimeout(() => this._render(), 150); }; const secLabel = el("span", "text-xs text-gray-400", "seconds"); configRow.append(durLabel, durInput, secLabel); } card.append(configRow); // Dependencies row if (otherIds.length > 0) { const depsRow = el("div", "px-3 pb-2 flex items-center gap-2 flex-wrap"); const label = el("span", "text-xs text-gray-500 shrink-0", "after:"); depsRow.append(label); for (const dep of otherIds) { const isSelected = stage.depends_on.includes(dep); const chip = el( "button", `text-xs px-2 py-0.5 rounded-full border transition-colors ${isSelected ? "bg-gray-900 text-white border-gray-900" : "border-gray-300 text-gray-500 hover:border-gray-400"}`, dep ); chip.type = "button"; chip.onmousedown = (e) => e.preventDefault(); chip.onclick = () => { clearTimeout(this._blurTimer); if (isSelected) { this.stages[index].depends_on = this.stages[index].depends_on.filter((d) => d !== dep); } else { this.stages[index].depends_on.push(dep); } this._render(); }; depsRow.append(chip); } card.append(depsRow); } return card; } _renderDagIfPresent() { const canvas = this.querySelector(".dag-canvas"); if (canvas) this._renderDag(canvas); } _renderDag(canvas) { canvas.innerHTML = ""; const named = this.stages.filter((s) => s.id.trim()); if (named.length === 0) { canvas.append(el("p", "text-xs text-gray-400 italic", "Add stages to see the pipeline graph")); return; } const levels = this._computeLevels(); const maxLevel = Math.max(0, ...Object.values(levels)); const columns = []; for (let l = 0; l <= maxLevel; l++) columns.push([]); for (const s of named) { const lvl = levels[s.id] || 0; columns[lvl].push(s); } const svgNS = "http://www.w3.org/2000/svg"; const NODE_W = 120; const NODE_H = 40; const COL_GAP = 60; const ROW_GAP = 12; const positions = {}; let totalW = 0; let totalH = 0; for (let col = 0; col <= maxLevel; col++) { const stages = columns[col]; for (let row = 0; row < stages.length; row++) { const x = col * (NODE_W + COL_GAP); const y = row * (NODE_H + ROW_GAP); positions[stages[row].id] = { x, y }; totalW = Math.max(totalW, x + NODE_W); totalH = Math.max(totalH, y + NODE_H); } } const PAD = 8; const svgW = totalW + PAD * 2; const svgH = totalH + PAD * 2; const svg = document.createElementNS(svgNS, "svg"); svg.setAttribute("width", svgW); svg.setAttribute("height", svgH); svg.style.display = "block"; // Arrowhead marker const defs = document.createElementNS(svgNS, "defs"); const marker = document.createElementNS(svgNS, "marker"); marker.setAttribute("id", "pb-arrow"); marker.setAttribute("viewBox", "0 0 10 10"); marker.setAttribute("refX", "10"); marker.setAttribute("refY", "5"); marker.setAttribute("markerWidth", "6"); marker.setAttribute("markerHeight", "6"); marker.setAttribute("orient", "auto-start-reverse"); const arrowPath = document.createElementNS(svgNS, "path"); arrowPath.setAttribute("d", "M 0 0 L 10 5 L 0 10 z"); arrowPath.setAttribute("fill", "#9ca3af"); marker.append(arrowPath); defs.append(marker); svg.append(defs); // Draw edges for (const s of named) { const to = positions[s.id]; if (!to) continue; for (const dep of s.depends_on) { const from = positions[dep]; if (!from) continue; const line = document.createElementNS(svgNS, "line"); line.setAttribute("x1", from.x + NODE_W + PAD); line.setAttribute("y1", from.y + NODE_H / 2 + PAD); line.setAttribute("x2", to.x + PAD); line.setAttribute("y2", to.y + NODE_H / 2 + PAD); line.setAttribute("stroke", "#d1d5db"); line.setAttribute("stroke-width", "2"); line.setAttribute("marker-end", "url(#pb-arrow)"); svg.append(line); } } // Draw nodes const TYPE_COLORS = { deploy: { bg: "#dbeafe", border: "#93c5fd", text: "#1e40af" }, wait: { bg: "#fef3c7", border: "#fcd34d", text: "#92400e" }, }; for (const s of named) { const pos = positions[s.id]; if (!pos) continue; const type = this._stageType(s.config); const colors = TYPE_COLORS[type] || TYPE_COLORS.deploy; const label = this._configLabel(s.config); const rect = document.createElementNS(svgNS, "rect"); rect.setAttribute("x", pos.x + PAD); rect.setAttribute("y", pos.y + PAD); rect.setAttribute("width", NODE_W); rect.setAttribute("height", NODE_H); rect.setAttribute("rx", "6"); rect.setAttribute("fill", colors.bg); rect.setAttribute("stroke", colors.border); rect.setAttribute("stroke-width", "1.5"); svg.append(rect); // Stage ID text const text = document.createElementNS(svgNS, "text"); text.setAttribute("x", pos.x + NODE_W / 2 + PAD); text.setAttribute("y", pos.y + NODE_H / 2 + PAD + (label ? -4 : 0)); text.setAttribute("text-anchor", "middle"); text.setAttribute("dominant-baseline", "middle"); text.setAttribute("fill", colors.text); text.setAttribute("font-size", "12"); text.setAttribute("font-weight", "600"); text.textContent = s.id.length > 14 ? s.id.slice(0, 13) + "…" : s.id; svg.append(text); // Config label (environment or duration) if (label) { const sub = document.createElementNS(svgNS, "text"); sub.setAttribute("x", pos.x + NODE_W / 2 + PAD); sub.setAttribute("y", pos.y + NODE_H / 2 + 10 + PAD); sub.setAttribute("text-anchor", "middle"); sub.setAttribute("dominant-baseline", "middle"); sub.setAttribute("fill", colors.text); sub.setAttribute("font-size", "9"); sub.setAttribute("opacity", "0.7"); sub.textContent = label; svg.append(sub); } } canvas.append(svg); } } function el(tag, className, text) { const e = document.createElement(tag); if (className) e.className = className; if (text) e.textContent = text; return e; } customElements.define("pipeline-builder", PipelineBuilder);