Azure Portal Copy resource link v1.0.1
Adds a copy button next to a resource's title in the Azure Portal that copies a link to the resource, with the resource name as the link text.
Script Content
// ==UserScript==
// @name Azure Portal: Copy Resource Link
// @namespace https://www.timhilton.xyz/user-scripts
// @version 1.0.1
// @description Adds a copy button next to a resource's blade title that copies a link to the resource, with the resource name as its text
// @author Tim Hilton using Claude
// @match https://portal.azure.com/*
// @grant none
// ==/UserScript==
(function () {
'use strict';
const LOG_PREFIX = '[Azure Portal: Copy Resource Link]';
const COPY_ICON_SVG = `
`;
const TICK_ICON_SVG = `
`;
const injectStyles = () => {
console.debug(`${LOG_PREFIX} Injecting styles`);
const style = document.createElement('style');
style.textContent = `
.azure-copy-resource-link {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
align-self: center;
width: 24px;
height: 24px;
margin-left: 4px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.25s ease-in-out;
&:hover {
background-color: rgba(128, 128, 128, 0.2);
}
& svg path {
fill: currentColor;
}
}
`;
document.head.appendChild(style);
};
// A resource blade's URL hash looks like
// #@tenant/resource/subscriptions/{sub}/resourceGroups/{rg}[/providers/{ns}/{type}/{name}...][/{menu item}]
// Returns the portal link prefix (up to and including "/resource") and the resource ID with any
// trailing menu item (e.g. "/overview", "/eventlogs") removed.
const parseResourceHash = (hash) => {
const match = hash.match(/^(#[^/]*\/resource)(\/subscriptions\/[^?]+)/i);
if (!match) {
return null;
}
const segments = decodeURIComponent(match[2]).split('/').filter(Boolean);
if (segments[2]?.toLowerCase() !== 'resourcegroups' || segments.length < 4) {
return null;
}
let idLength = 4;
const providersIndex = segments.findIndex((s) => s.toLowerCase() === 'providers');
if (providersIndex === 4) {
// After the namespace, a resource ID has type/name pairs, so an odd count means a menu item follows
const afterNamespace = segments.length - 6;
idLength = afterNamespace % 2 === 0 ? segments.length : segments.length - 1;
}
const idSegments = segments.slice(0, idLength);
return {
prefix: match[1],
resourceId: `/${idSegments.join('/')}`,
name: idSegments[idSegments.length - 1],
};
};
// The title is the resource name, followed by a span holding e.g. " | Activity log" on menu pages
const getBladeName = (titleHeading) => titleHeading.firstChild?.nodeType === Node.TEXT_NODE ? titleHeading.firstChild.textContent.trim() : '';
const escapeHtml = (text) =>
text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
// Rich-text editors paste the HTML link, apps reading the web Markdown format paste the Markdown link,
// and anything else pastes the name
const writeClipboard = async (displayText, href) => {
if (typeof ClipboardItem === 'undefined' || !navigator.clipboard.write) {
await navigator.clipboard.writeText(displayText);
return;
}
const plain = new Blob([displayText], { type: 'text/plain' });
const html = new Blob([`${escapeHtml(displayText)}`], { type: 'text/html' });
const markdown = new Blob([`[${displayText}](${href})`], { type: 'web text/markdown' });
try {
await navigator.clipboard.write([
new ClipboardItem({ 'text/plain': plain, 'text/html': html, 'web text/markdown': markdown }),
]);
} catch (_) {
// A browser that doesn't support web custom formats rejects the whole write, so retry without it
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': plain, 'text/html': html })]);
}
};
const setButtonResource = (button, name, href) => {
button.dataset.name = name;
button.dataset.href = href;
button.setAttribute('aria-label', `Copy link to ${name}`);
};
// The name and link are read from the button when clicked, as setupCopyButtons updates them when
// the portal reuses the blade for a different resource
const createCopyButton = () => {
const button = document.createElement('span');
button.classList.add('azure-copy-resource-link');
button.innerHTML = COPY_ICON_SVG;
button.setAttribute('role', 'button');
button.setAttribute('tabindex', '0');
button.setAttribute('title', 'Copy link to resource');
const copy = (e) => {
e.stopPropagation();
const { name, href } = button.dataset;
console.debug(`${LOG_PREFIX} Copy button clicked for: ${name}`);
writeClipboard(name, href)
.then(() => {
console.log(`${LOG_PREFIX} ✅ Copied link to ${name}: ${href}`);
button.innerHTML = TICK_ICON_SVG;
setTimeout(() => {
button.innerHTML = COPY_ICON_SVG;
}, 1000);
})
.catch((err) => console.log(`${LOG_PREFIX} ❌ Failed to copy: ${err}`));
};
button.addEventListener('click', copy);
button.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
copy(e);
}
});
return button;
};
const setupCopyButtons = () => {
const resource = parseResourceHash(location.hash);
if (!resource) {
return;
}
// The URL only describes the most recently opened blade, so only the blade titled with this
// resource's name is given its link. Earlier blades keep the link they were given when they were
// the latest. The portal reuses a blade's title element when another resource is picked (e.g. from
// the All resources list), so a button whose title has changed is removed until the URL catches up.
document.querySelectorAll('h2.fxs-blade-title-titleText').forEach((titleHeading) => {
const name = getBladeName(titleHeading);
let button = titleHeading.nextElementSibling?.classList.contains('azure-copy-resource-link')
? titleHeading.nextElementSibling
: null;
if (button?.dataset.name === name) {
return;
}
// Child resources such as deployment slots are titled e.g. "test (storyhomes-integration/test)"
if (name.replace(/ \(.*\)$/, '').toLowerCase() !== resource.name.toLowerCase()) {
if (button) {
console.debug(`${LOG_PREFIX} Blade title changed from ${button.dataset.name}, removing button`);
button.remove();
}
return;
}
if (!button) {
button = createCopyButton();
titleHeading.after(button);
}
setButtonResource(button, name, `${location.origin}/${resource.prefix}${resource.resourceId}`);
console.log(`${LOG_PREFIX} ✅ Copy button set for ${name}`);
});
};
injectStyles();
console.debug(`${LOG_PREFIX} Initialising script`);
setupCopyButtons();
// The portal is a SPA: blades open, close and re-render without a page load, so watch the body.
// Batch mutations into one setup pass per animation frame to keep the callback cheap.
let observerFireCount = 0;
let setupPending = false;
const scheduleSetup = () => {
observerFireCount++;
if (setupPending) {
return;
}
setupPending = true;
requestAnimationFrame(() => {
setupPending = false;
console.debug(`${LOG_PREFIX} Change detected (count: ${observerFireCount}), re-running setup`);
setupCopyButtons();
});
};
// characterData catches a blade title's text being changed in place
new MutationObserver(scheduleSetup).observe(document.body, { childList: true, subtree: true, characterData: true });
window.addEventListener('hashchange', scheduleSetup);
console.log(`${LOG_PREFIX} 🎉 Script initialised successfully`);
})();