Making Concourse CI Links Clickable with Violentmonkey and Claude

Making Concourse CI Links Clickable with Violentmonkey and Claude

TL;DR

Turn plain text URLs in Concourse CI logs into clickable links:

  1. Install Violentmonkey extension in Chrome
  2. Create a userscript that finds and converts URLs to clickable links
  3. The script works on all Concourse pages and handles dynamic content

The Problem

Concourse CI displays URLs in build logs as plain text, making it tedious to copy and paste them into a new tab. This is especially annoying when dealing with test reports, artifacts, and external service links that appear frequently in CI/CD pipelines.

The Solution: A Userscript

Step 1: Install Violentmonkey

Violentmonkey is a browser extension that lets you run custom JavaScript on any website.

To install in Chrome:

  1. Open Chrome Web Store
  2. Search for "Violentmonkey"
  3. Click "Add to Chrome"
  4. Confirm the installation

Step 2: Using Claude to Analyze the Page

This is where things get interesting. Instead of manually inspecting the Concourse interface, I used Claude's Playwright MCP server to navigate and analyze the page structure.

Installing the Playwright MCP Server

First, add the Playwright MCP server to Claude:

claude mcp add playwright npx @playwright/mcp@latest

This gives Claude the ability to control a browser, navigate to pages, take screenshots, and analyze DOM elements.

The Discovery Process

Here's how I prompted Claude to help me create the userscript:

  1. Initial Navigation

    "I want to develop a Violentmonkey userscript that makes HTML links 
    clickable on my Concourse CI webpage. Use the Playwright MCP server 
    to navigate to my Concourse instance and prompt me so I can login."
    
  2. Waiting for Authentication

    Since my Concourse instance uses Google OAuth, I told Claude:

    "I'm logged in. Navigate to the pipeline page."
    

    This let me handle the authentication manually while Claude waited.

  3. Navigating to the Right Page

    Concourse has a specific structure: pipelines → jobs → builds → tasks. I guided Claude:

    "Navigate to the [pipeline-name] pipeline, and navigate to the 
    [job-name] job and search the [task-name] task for HTML links 
    to convert to anchor tags."
    

    Then I clarified:

    "Note that once you land on the jobs page, you will see a list of builds. 
    You have to click on an actual build before then clicking on the task."
    
  4. Identifying the Links

    Initially, I asked Claude to focus on specific types of URLs (Google Cloud Storage links), but then realized I wanted something more universal:

    "Tweak the script so that it works for *all* http/https URLs that 
    appear on the page, not just specific URLs"
    

Step 3: The Final Userscript

Here's the userscript that resulted from this collaboration:

// ==UserScript==
// @name         Concourse CI - Make URLs Clickable
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Convert all plain text HTTP/HTTPS URLs to clickable links in Concourse CI logs
// @author       Your Name
// @match        https://your-concourse-instance.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // Function to convert URLs in text nodes to clickable links
    function makeUrlsClickable(node) {
        // Skip if this node has already been processed
        if (node.dataset && node.dataset.urlsProcessed) {
            return;
        }

        // Regular expression to match all http and https URLs
        const urlRegex = /https?:\/\/[^\s<>"]+/g;

        // Get all text nodes within the given node
        const walker = document.createTreeWalker(
            node,
            NodeFilter.SHOW_TEXT,
            {
                acceptNode: function(node) {
                    // Skip if parent is already a link or script/style
                    const parent = node.parentNode;
                    if (parent.tagName === 'A' || 
                        parent.tagName === 'SCRIPT' || 
                        parent.tagName === 'STYLE' ||
                        parent.tagName === 'NOSCRIPT') {
                        return NodeFilter.FILTER_REJECT;
                    }
                    // Only process if contains our target URLs
                    if (urlRegex.test(node.nodeValue)) {
                        return NodeFilter.FILTER_ACCEPT;
                    }
                    return NodeFilter.FILTER_REJECT;
                }
            }
        );

        const textNodes = [];
        while (walker.nextNode()) {
            textNodes.push(walker.currentNode);
        }

        // Process each text node
        textNodes.forEach(textNode => {
            const text = textNode.nodeValue;
            const matches = text.match(urlRegex);
            
            if (matches && matches.length > 0) {
                const parent = textNode.parentNode;
                const fragment = document.createDocumentFragment();
                let lastIndex = 0;

                // Reset regex for exec
                urlRegex.lastIndex = 0;
                let match;
                
                while ((match = urlRegex.exec(text)) !== null) {
                    // Add text before the URL
                    if (match.index > lastIndex) {
                        fragment.appendChild(
                            document.createTextNode(text.substring(lastIndex, match.index))
                        );
                    }

                    // Create clickable link
                    const link = document.createElement('a');
                    link.href = match[0];
                    link.textContent = match[0];
                    link.target = '_blank';
                    link.rel = 'noopener noreferrer';
                    link.style.color = '#4FC3F7';
                    link.style.textDecoration = 'underline';
                    link.style.wordBreak = 'break-all';
                    fragment.appendChild(link);

                    lastIndex = match.index + match[0].length;
                }

                // Add remaining text after last URL
                if (lastIndex < text.length) {
                    fragment.appendChild(
                        document.createTextNode(text.substring(lastIndex))
                    );
                }

                // Replace the text node with the fragment containing links
                parent.replaceChild(fragment, textNode);
            }
        });

        // Mark this node as processed
        if (node.dataset) {
            node.dataset.urlsProcessed = 'true';
        }
    }

    // Function to process the page
    function processPage() {
        // Find all elements that might contain logs
        const logContainers = document.querySelectorAll('pre, .build-step, .step-body, [class*="output"], [class*="log"]');
        
        if (logContainers.length > 0) {
            logContainers.forEach(container => {
                makeUrlsClickable(container);
            });
        } else {
            // If no specific containers found, process the whole body
            makeUrlsClickable(document.body);
        }
    }

    // Initial processing
    setTimeout(processPage, 1000);

    // Set up observer for dynamic content
    const observer = new MutationObserver((mutations) => {
        mutations.forEach(mutation => {
            if (mutation.type === 'childList') {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        // Check if this is a log container or contains log content
                        if (node.tagName === 'PRE' || 
                            node.classList && (
                                node.classList.contains('build-step') ||
                                node.classList.contains('step-body') ||
                                Array.from(node.classList).some(c => c.includes('output')) ||
                                Array.from(node.classList).some(c => c.includes('log'))
                            )) {
                            makeUrlsClickable(node);
                        } else if (node.querySelector) {
                            // Check for log containers within the added node
                            const containers = node.querySelectorAll('pre, .build-step, .step-body, [class*="output"], [class*="log"]');
                            containers.forEach(container => makeUrlsClickable(container));
                        }
                    }
                });
            }
        });
    });

    // Start observing the body for changes
    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    // Also run when URL changes (for single-page navigation)
    let lastUrl = location.href;
    new MutationObserver(() => {
        const url = location.href;
        if (url !== lastUrl) {
            lastUrl = url;
            setTimeout(processPage, 1000);
        }
    }).observe(document, {subtree: true, childList: true});

})();

Step 4: Installing the Userscript

  1. Click the Violentmonkey icon in your browser toolbar
  2. Click the "+" button or "Create a new script"
  3. Replace the default content with the script above
  4. Update the @match line to point to your Concourse instance URL
  5. Save the script (Ctrl+S or Cmd+S)

Conclusion

Combining Claude's ability to navigate web pages (via Playwright MCP) with browser userscripts creates a powerful workflow for enhancing any web application.

The next time you find yourself repeatedly copying and pasting URLs from a web application, consider creating a userscript. And if the page structure is complex, let Claude help you navigate and analyze it!

Webmentions

No mentions yet.

🙏🙏🙏

Since you've made it this far, sharing this article on your favorite social media network would be highly appreciated 💖! For feedback, please ping me on Twitter.