630 lines
21 KiB
JavaScript
630 lines
21 KiB
JavaScript
/**
|
|
* <pipeline-builder> 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:
|
|
* <pipeline-builder data-target="pipeline-stages"></pipeline-builder>
|
|
* <textarea id="pipeline-stages" name="stages_json" hidden></textarea>
|
|
*/
|
|
|
|
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 = `<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>`;
|
|
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);
|