mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-16 19:03:26 +00:00
404 lines
18 KiB
JavaScript
404 lines
18 KiB
JavaScript
// Renderer for the "Create connector namespace" wizard page. Mirrors the
|
|
// portal's CreateConnectorGatewayPage: subscription -> resource group
|
|
// (existing or new) -> region -> name (live availability) -> managed identity
|
|
// (system + user-assigned) -> real ARM provisioning.
|
|
|
|
import { baseStyles, brandMark } from "./renderer.mjs";
|
|
|
|
function esc(s) {
|
|
return String(s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
}
|
|
|
|
// Connector namespace regions — kept in sync with the portal's
|
|
// CONNECTOR_NAMESPACE_REGIONS list (constants.ts).
|
|
const REGIONS = [
|
|
["australiaeast", "Australia East"], ["brazilsouth", "Brazil South"],
|
|
["canadacentral", "Canada Central"], ["canadaeast", "Canada East"],
|
|
["centralindia", "Central India"], ["centralus", "Central US"],
|
|
["eastasia", "East Asia"], ["eastus", "East US"], ["eastus2", "East US 2"],
|
|
["francecentral", "France Central"], ["germanywestcentral", "Germany West Central"],
|
|
["italynorth", "Italy North"], ["japaneast", "Japan East"],
|
|
["koreacentral", "Korea Central"], ["northcentralus", "North Central US"],
|
|
["northeurope", "North Europe"], ["norwayeast", "Norway East"],
|
|
["polandcentral", "Poland Central"], ["southafricanorth", "South Africa North"],
|
|
["southcentralus", "South Central US"], ["southindia", "South India"],
|
|
["southeastasia", "Southeast Asia"], ["spaincentral", "Spain Central"],
|
|
["swedencentral", "Sweden Central"], ["switzerlandnorth", "Switzerland North"],
|
|
["uaenorth", "UAE North"], ["uksouth", "UK South"],
|
|
["westcentralus", "West Central US"], ["westus2", "West US 2"],
|
|
["westus3", "West US 3"],
|
|
];
|
|
|
|
const DEFAULT_REGION = "eastus";
|
|
|
|
export function renderCreateNamespaceHtml(subscriptions, preselectedSub = "", capabilityToken = "") {
|
|
const subOptions = subscriptions.map((s) =>
|
|
`<option value="${esc(s.id)}"${s.id === preselectedSub ? " selected" : ""}>${esc(s.name)} (${esc(s.id.slice(0, 8))}\u2026)</option>`
|
|
).join("");
|
|
|
|
const regionOptions = REGIONS.map(([v, l]) =>
|
|
`<option value="${v}"${v === DEFAULT_REGION ? " selected" : ""}>${l}</option>`
|
|
).join("");
|
|
|
|
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Create Connector Namespace</title>${baseStyles()}
|
|
<style>
|
|
.crt-back { display:inline-flex; align-items:center; gap:.35rem; background:none; border:0; color:var(--fg-muted); font:inherit; font-size:.8rem; cursor:pointer; padding:0; margin-bottom:.65rem; }
|
|
.crt-back:hover { color:var(--accent); }
|
|
.field { margin-bottom: 1rem; }
|
|
.field > label { display:block; font-size:.8rem; font-weight:600; margin-bottom:.3rem; }
|
|
.field .hint { font-size:.72rem; color:var(--fg-muted); margin-top:.25rem; }
|
|
.crt-input { width:100%; padding:.45rem .65rem; border-radius:4px; border:1px solid var(--border-strong); background:var(--bg); color:var(--fg); font-size:.85rem; font-family:inherit; box-sizing:border-box; }
|
|
.crt-input:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 1px var(--accent); }
|
|
.seg { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; margin-bottom:.4rem; }
|
|
.seg button { appearance:none; border:0; background:var(--bg); color:var(--fg-muted); font:inherit; font-size:.78rem; padding:.32rem .7rem; cursor:pointer; }
|
|
.seg button.active { background:var(--accent); color:#fff; }
|
|
.name-status { font-size:.74rem; margin-top:.3rem; min-height:1rem; }
|
|
.name-status.ok { color:var(--accent); }
|
|
.name-status.bad { color:var(--danger); }
|
|
.name-status.checking { color:var(--fg-muted); }
|
|
.idn-row { display:flex; align-items:center; gap:.5rem; padding:.35rem .15rem; }
|
|
.idn-row label { font-size:.82rem; }
|
|
.uami-box { border:1px solid var(--border); border-radius:6px; padding:.4rem .55rem; max-height:160px; overflow-y:auto; }
|
|
.uami-item { display:flex; align-items:center; gap:.55rem; padding:.3rem .15rem; font-size:.8rem; }
|
|
.uami-item .meta { color:var(--fg-muted); font-size:.72rem; }
|
|
.uami-empty { font-size:.78rem; color:var(--fg-muted); padding:.3rem .15rem; }
|
|
.crt-actions { display:flex; gap:.6rem; justify-content:flex-end; align-items:center; margin-top:1.4rem; }
|
|
.btn { appearance:none; font:inherit; font-size:.83rem; padding:.5rem 1rem; border-radius:6px; cursor:pointer; border:1px solid var(--border-strong); background:var(--bg); color:var(--fg); }
|
|
.btn:hover { border-color:var(--accent); }
|
|
.btn.primary { background:var(--accent); border-color:var(--accent); color:#fff; }
|
|
.btn.primary:hover { background:var(--accent-hover); border-color:var(--accent-hover); }
|
|
.btn:disabled { opacity:.5; cursor:not-allowed; }
|
|
.btn.primary:disabled:hover { background:var(--accent); border-color:var(--accent); }
|
|
.progress { display:none; margin-top:1rem; padding:.7rem .85rem; border-radius:6px; border:1px solid var(--border); background:var(--bg-hover); font-size:.82rem; }
|
|
.progress .spin { display:inline-block; width:14px; height:14px; border:2px solid var(--bg-pill); border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; vertical-align:-2px; margin-right:.5rem; }
|
|
.progress.error { border-color:var(--danger); color:var(--danger); }
|
|
.progress.success { border-color:var(--accent); }
|
|
@keyframes spin { 0% { transform:rotate(0deg); } 100% { transform:rotate(360deg); } }
|
|
</style></head><body>
|
|
<button class="crt-back" id="back-btn" type="button">\u2190 Back to namespaces</button>
|
|
<div class="header brand-head">
|
|
<h1>${brandMark(28, "create")}<span>Create connector namespace</span></h1>
|
|
<div class="sub">Provisions a real Azure connector namespace (Microsoft.Web/connectorGateways) in your subscription.</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="sub-select">Subscription</label>
|
|
<select id="sub-select" class="crt-input">
|
|
<option value="">-- Select subscription --</option>
|
|
${subOptions}
|
|
</select>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Resource group</label>
|
|
<div class="seg" id="rg-mode">
|
|
<button type="button" data-mode="existing" class="active">Use existing</button>
|
|
<button type="button" data-mode="new">Create new</button>
|
|
</div>
|
|
<select id="rg-select" class="crt-input"><option value="">-- Select subscription first --</option></select>
|
|
<input id="rg-new" class="crt-input" type="text" placeholder="New resource group name" autocomplete="off" spellcheck="false" style="display:none;">
|
|
<div class="hint" id="rg-hint">Pick the resource group the namespace will live in.</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="region-select">Region</label>
|
|
<select id="region-select" class="crt-input">${regionOptions}</select>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="name-input">Name</label>
|
|
<input id="name-input" class="crt-input" type="text" placeholder="my-connector-namespace" autocomplete="off" spellcheck="false">
|
|
<div class="name-status" id="name-status"></div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>Managed identity</label>
|
|
<div class="idn-row">
|
|
<input type="checkbox" id="sys-identity">
|
|
<label for="sys-identity">System-assigned</label>
|
|
</div>
|
|
<div class="idn-row">
|
|
<input type="checkbox" id="uami-toggle">
|
|
<label for="uami-toggle">User-assigned</label>
|
|
</div>
|
|
<div class="uami-box" id="uami-box" style="display:none;"><div class="uami-empty">Select a subscription to list identities.</div></div>
|
|
</div>
|
|
|
|
<div class="crt-actions">
|
|
<button class="btn" id="cancel-btn" type="button">Cancel</button>
|
|
<button class="btn primary" id="create-btn" type="button" disabled>Create</button>
|
|
</div>
|
|
|
|
<div class="progress" id="progress"></div>
|
|
|
|
<script>
|
|
const connectorNamespaceToken = ${JSON.stringify(capabilityToken)};
|
|
const rawFetch = window.fetch.bind(window);
|
|
window.fetch = (input, init = {}) => {
|
|
const url = typeof input === "string" ? input : input && input.url;
|
|
if (url && (url.startsWith("/api/") || url.startsWith("/oauth-status"))) {
|
|
const next = { ...init };
|
|
const headers = new Headers(next.headers || (typeof input !== "string" ? input.headers : undefined));
|
|
headers.set("x-connector-namespace-token", connectorNamespaceToken);
|
|
next.headers = headers;
|
|
return rawFetch(input, next);
|
|
}
|
|
return rawFetch(input, init);
|
|
};
|
|
|
|
const subSelect = document.getElementById("sub-select");
|
|
const rgModeWrap = document.getElementById("rg-mode");
|
|
const rgSelect = document.getElementById("rg-select");
|
|
const rgNew = document.getElementById("rg-new");
|
|
const rgHint = document.getElementById("rg-hint");
|
|
const regionSelect = document.getElementById("region-select");
|
|
const nameInput = document.getElementById("name-input");
|
|
const nameStatus = document.getElementById("name-status");
|
|
const sysIdentity = document.getElementById("sys-identity");
|
|
const uamiBox = document.getElementById("uami-box");
|
|
const uamiToggle = document.getElementById("uami-toggle");
|
|
const createBtn = document.getElementById("create-btn");
|
|
const backBtn = document.getElementById("back-btn");
|
|
const cancelBtn = document.getElementById("cancel-btn");
|
|
const progress = document.getElementById("progress");
|
|
|
|
let rgMode = "existing";
|
|
let nameCheck = "idle"; // idle | checking | available | taken | error
|
|
let creating = false;
|
|
let nameTimer = null;
|
|
let checkSeq = 0;
|
|
let resourceGroupsSeq = 0;
|
|
let identitiesSeq = 0;
|
|
|
|
backBtn.onclick = () => { if (!creating) window.location.href = "/setup"; };
|
|
cancelBtn.onclick = () => { if (!creating) window.location.href = "/setup"; };
|
|
|
|
function setFormLocked(locked) {
|
|
for (const control of document.querySelectorAll("button, input, select")) {
|
|
if (locked) {
|
|
if (control.dataset.preCreateDisabled === undefined) {
|
|
control.dataset.preCreateDisabled = control.disabled ? "1" : "0";
|
|
}
|
|
control.disabled = true;
|
|
} else {
|
|
control.disabled = control.dataset.preCreateDisabled === "1";
|
|
delete control.dataset.preCreateDisabled;
|
|
}
|
|
}
|
|
document.body.setAttribute("aria-busy", locked ? "true" : "false");
|
|
}
|
|
|
|
function escH(s) { return String(s||"").replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); }
|
|
|
|
function effectiveRg() {
|
|
return rgMode === "new" ? rgNew.value.trim() : rgSelect.value;
|
|
}
|
|
|
|
function nameError() {
|
|
const v = nameInput.value.trim();
|
|
if (!v) return "Name is required";
|
|
if (!/^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(v)) return "Start with a letter/number; letters, numbers and hyphens only";
|
|
if (v.length > 64) return "Max 64 characters";
|
|
return "";
|
|
}
|
|
|
|
function refreshButton() {
|
|
const ok = !!subSelect.value && !!effectiveRg() && !!regionSelect.value &&
|
|
!nameError() && nameCheck === "available" && !creating;
|
|
createBtn.disabled = !ok;
|
|
}
|
|
|
|
// --- Resource group mode toggle ---
|
|
rgModeWrap.querySelectorAll("button").forEach((b) => {
|
|
b.onclick = () => {
|
|
rgMode = b.dataset.mode;
|
|
rgModeWrap.querySelectorAll("button").forEach((x) => x.classList.toggle("active", x === b));
|
|
rgSelect.style.display = rgMode === "existing" ? "block" : "none";
|
|
rgNew.style.display = rgMode === "new" ? "block" : "none";
|
|
rgHint.textContent = rgMode === "new"
|
|
? "A new resource group is created in the selected region."
|
|
: "Pick the resource group the namespace will live in.";
|
|
scheduleNameCheck();
|
|
refreshButton();
|
|
};
|
|
});
|
|
|
|
uamiToggle.addEventListener("change", () => {
|
|
if (uamiToggle.checked) {
|
|
uamiBox.style.display = "";
|
|
loadIdentities();
|
|
} else {
|
|
uamiBox.style.display = "none";
|
|
}
|
|
});
|
|
|
|
// --- Subscription change: load resource groups + identities ---
|
|
subSelect.addEventListener("change", () => {
|
|
loadResourceGroups();
|
|
if (uamiToggle.checked) loadIdentities();
|
|
scheduleNameCheck();
|
|
refreshButton();
|
|
});
|
|
|
|
async function loadResourceGroups() {
|
|
const seq = ++resourceGroupsSeq;
|
|
const sub = subSelect.value;
|
|
if (!sub) { rgSelect.innerHTML = '<option value="">-- Select subscription first --</option>'; return; }
|
|
rgSelect.innerHTML = '<option value="">Loading\u2026</option>';
|
|
try {
|
|
const res = await fetch("/api/resource-groups?subscriptionId=" + encodeURIComponent(sub));
|
|
const data = await res.json();
|
|
if (seq !== resourceGroupsSeq || sub !== subSelect.value) return;
|
|
if (data.error) { rgSelect.innerHTML = '<option value="">Error loading groups</option>'; return; }
|
|
const opts = (data.resourceGroups || []).map((g) =>
|
|
'<option value="' + escH(g.name) + '">' + escH(g.name) + ' (' + escH(g.location) + ')</option>'
|
|
).join("");
|
|
rgSelect.innerHTML = '<option value="">-- Select resource group --</option>' + opts;
|
|
} catch (e) {
|
|
if (seq !== resourceGroupsSeq) return;
|
|
rgSelect.innerHTML = '<option value="">Error loading groups</option>';
|
|
}
|
|
}
|
|
|
|
async function loadIdentities() {
|
|
const seq = ++identitiesSeq;
|
|
const sub = subSelect.value;
|
|
if (!sub) { uamiBox.innerHTML = '<div class="uami-empty">Select a subscription to list identities.</div>'; return; }
|
|
uamiBox.innerHTML = '<div class="uami-empty">Loading identities\u2026</div>';
|
|
try {
|
|
const res = await fetch("/api/identities?subscriptionId=" + encodeURIComponent(sub));
|
|
const data = await res.json();
|
|
if (seq !== identitiesSeq || sub !== subSelect.value) return;
|
|
if (data.error) { uamiBox.innerHTML = '<div class="uami-empty">Error loading identities</div>'; return; }
|
|
const ids = data.identities || [];
|
|
if (!ids.length) { uamiBox.innerHTML = '<div class="uami-empty">No user-assigned identities in this subscription.</div>'; return; }
|
|
uamiBox.innerHTML = ids.map((id, index) =>
|
|
'<div class="uami-item"><input type="checkbox" class="uami-cb" value="' + escH(id.id) + '" id="uami-' + index + '">' +
|
|
'<label for="uami-' + index + '">' + escH(id.name) +
|
|
' <span class="meta">' + escH(id.resourceGroup) + ' \u2022 ' + escH(id.location) + '</span></label></div>'
|
|
).join("");
|
|
} catch (e) {
|
|
if (seq !== identitiesSeq) return;
|
|
uamiBox.innerHTML = '<div class="uami-empty">Error loading identities</div>';
|
|
} finally {
|
|
if (creating) setFormLocked(true);
|
|
}
|
|
}
|
|
|
|
function selectedUserAssignedIds() {
|
|
if (!uamiToggle.checked) return [];
|
|
return [...uamiBox.querySelectorAll(".uami-cb:checked")].map((c) => c.value);
|
|
}
|
|
|
|
// --- Name availability check (debounced) ---
|
|
nameInput.addEventListener("input", () => { scheduleNameCheck(); });
|
|
rgSelect.addEventListener("change", () => { scheduleNameCheck(); refreshButton(); });
|
|
rgNew.addEventListener("input", () => { scheduleNameCheck(); refreshButton(); });
|
|
regionSelect.addEventListener("change", refreshButton);
|
|
|
|
function scheduleNameCheck() {
|
|
clearTimeout(nameTimer);
|
|
checkSeq++;
|
|
const err = nameError();
|
|
if (err) {
|
|
nameCheck = "idle";
|
|
nameStatus.className = "name-status bad";
|
|
nameStatus.textContent = nameInput.value.trim() ? err : "";
|
|
refreshButton();
|
|
return;
|
|
}
|
|
if (!subSelect.value || !effectiveRg()) {
|
|
nameCheck = "idle";
|
|
nameStatus.className = "name-status";
|
|
nameStatus.textContent = "Select a subscription and resource group to check availability.";
|
|
refreshButton();
|
|
return;
|
|
}
|
|
nameCheck = "checking";
|
|
nameStatus.className = "name-status checking";
|
|
nameStatus.textContent = "Checking availability\u2026";
|
|
refreshButton();
|
|
nameTimer = setTimeout(runNameCheck, 450);
|
|
}
|
|
|
|
async function runNameCheck() {
|
|
const seq = checkSeq;
|
|
const sub = subSelect.value, rg = effectiveRg(), name = nameInput.value.trim();
|
|
try {
|
|
const res = await fetch("/api/check-name?subscriptionId=" + encodeURIComponent(sub) +
|
|
"&resourceGroup=" + encodeURIComponent(rg) + "&name=" + encodeURIComponent(name));
|
|
const data = await res.json();
|
|
if (seq !== checkSeq) return; // stale
|
|
if (data.error) {
|
|
nameCheck = "error";
|
|
nameStatus.className = "name-status bad";
|
|
nameStatus.textContent = "Could not check availability";
|
|
} else if (data.available) {
|
|
nameCheck = "available";
|
|
nameStatus.className = "name-status ok";
|
|
nameStatus.textContent = "\u2713 Available";
|
|
} else {
|
|
nameCheck = "taken";
|
|
nameStatus.className = "name-status bad";
|
|
nameStatus.textContent = "\u2717 Name already in use in this resource group";
|
|
}
|
|
} catch (e) {
|
|
if (seq !== checkSeq) return;
|
|
nameCheck = "error";
|
|
nameStatus.className = "name-status bad";
|
|
nameStatus.textContent = "Could not check availability";
|
|
}
|
|
refreshButton();
|
|
}
|
|
|
|
// --- Create ---
|
|
createBtn.onclick = async () => {
|
|
if (createBtn.disabled) return;
|
|
const request = {
|
|
subscriptionId: subSelect.value,
|
|
resourceGroup: effectiveRg(),
|
|
createNewResourceGroup: rgMode === "new",
|
|
region: regionSelect.value,
|
|
name: nameInput.value.trim(),
|
|
enableSystemIdentity: sysIdentity.checked,
|
|
userAssignedIds: selectedUserAssignedIds(),
|
|
};
|
|
creating = true;
|
|
setFormLocked(true);
|
|
createBtn.textContent = "Creating\u2026";
|
|
progress.className = "progress";
|
|
progress.style.display = "block";
|
|
progress.innerHTML = '<span class="spin"></span>' + (rgMode === "new" ? "Creating resource group and namespace\u2026" : "Creating connector namespace\u2026");
|
|
try {
|
|
const res = await fetch("/api/create-namespace", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(request),
|
|
});
|
|
const data = await res.json();
|
|
if (data.ok) {
|
|
progress.className = "progress success";
|
|
progress.textContent = "\u2713 Created \u201c" + request.name + "\u201d. Opening\u2026";
|
|
window.location.href = "/";
|
|
return;
|
|
}
|
|
progress.className = "progress error";
|
|
progress.textContent = data.error || "Failed to create connector namespace.";
|
|
} catch (e) {
|
|
progress.className = "progress error";
|
|
progress.textContent = "Failed to create connector namespace.";
|
|
}
|
|
creating = false;
|
|
setFormLocked(false);
|
|
createBtn.textContent = "Create";
|
|
refreshButton();
|
|
};
|
|
|
|
// --- Init (subscription may be preselected from the setup page) ---
|
|
if (subSelect.value) { loadResourceGroups(); if (uamiToggle.checked) loadIdentities(); }
|
|
scheduleNameCheck();
|
|
</script></body></html>`;
|
|
}
|