diff --git a/api/main.py b/api/main.py
index 158e3c58..4076fdb7 100644
--- a/api/main.py
+++ b/api/main.py
@@ -98,6 +98,7 @@ async def lifespan(app: FastAPI): # pylint: disable=redefined-outer-name
metrics = Metrics()
app = FastAPI(lifespan=lifespan, debug=True, docs_url=None, redoc_url=None)
+
db = Database(service=(os.getenv('MONGO_SERVICE') or 'mongodb://db:27017'))
auth = Authentication(token_url="user/login")
pubsub = None # pylint: disable=invalid-name
@@ -1456,6 +1457,72 @@ async def icons(icon_name: str):
return FileResponse(icon_path)
+@app.get('/static/css/{filename}')
+async def serve_css(filename: str):
+ """Serve CSS files from api/static/css/"""
+ metrics.add('http_requests_total', 1)
+ root_dir = os.path.dirname(os.path.abspath(__file__))
+ print(f"[CSS] Request for: {filename}")
+ print(f"[CSS] root_dir: {root_dir}")
+ # Security: only allow safe filenames
+ if not re.match(r'^[A-Za-z0-9_.-]+\.css$', filename):
+ print(f"[CSS] Invalid filename pattern: {filename}")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid filename"
+ )
+ file_path = os.path.join(root_dir, 'static', 'css', filename)
+ print(f"[CSS] Looking for file at: {file_path}")
+ print(f"[CSS] File exists: {os.path.isfile(file_path)}")
+ if not os.path.isfile(file_path):
+ print(f"[CSS] File not found: {file_path}")
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="File not found"
+ )
+ print(f"[CSS] Serving file: {file_path}")
+ return FileResponse(
+ file_path,
+ media_type="text/css",
+ headers={
+ 'Cache-Control': 'public, max-age=3600', # Cache for 1 hour
+ }
+ )
+
+
+@app.get('/static/js/{filename}')
+async def serve_js(filename: str):
+ """Serve JavaScript files from api/static/js/"""
+ metrics.add('http_requests_total', 1)
+ root_dir = os.path.dirname(os.path.abspath(__file__))
+ print(f"[JS] Request for: {filename}")
+ print(f"[JS] root_dir: {root_dir}")
+ # Security: only allow safe filenames
+ if not re.match(r'^[A-Za-z0-9_.-]+\.js$', filename):
+ print(f"[JS] Invalid filename pattern: {filename}")
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Invalid filename"
+ )
+ file_path = os.path.join(root_dir, 'static', 'js', filename)
+ print(f"[JS] Looking for file at: {file_path}")
+ print(f"[JS] File exists: {os.path.isfile(file_path)}")
+ if not os.path.isfile(file_path):
+ print(f"[JS] File not found: {file_path}")
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="File not found"
+ )
+ print(f"[JS] Serving file: {file_path}")
+ return FileResponse(
+ file_path,
+ media_type="application/javascript",
+ headers={
+ 'Cache-Control': 'public, max-age=3600', # Cache for 1 hour
+ }
+ )
+
+
@app.get('/metrics')
async def get_metrics():
"""Get metrics"""
diff --git a/api/static/css/viewer.css b/api/static/css/viewer.css
new file mode 100644
index 00000000..1601451d
--- /dev/null
+++ b/api/static/css/viewer.css
@@ -0,0 +1,332 @@
+/* ====================================================================
+ MAESTRO API VIEWER - STYLESHEET
+
+ This stylesheet provides all styling for the KernelCI API viewer interface.
+ It includes styles for navigation, tables, modals, and the Trees matrix view.
+ ==================================================================== */
+
+/* ====================================================================
+ BASE STYLES
+ ==================================================================== */
+body {
+ font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+ font-size: 14px;
+ line-height: 20px;
+ color: #333333;
+ background-color: #ffffff;
+ padding: 10px;
+ margin: 0;
+}
+
+/* ====================================================================
+ NAVIGATION MENU
+ ==================================================================== */
+#menu {
+ background-color: #f5f5f5;
+ border-bottom: 1px solid #e5e5e5;
+ border-top: 1px solid #e5e5e5;
+ margin-bottom: 20px;
+ padding: 10px 0;
+}
+
+#menu a {
+ color: #999999;
+ font-size: 14px;
+ font-weight: bold;
+ margin-right: 10px;
+ text-decoration: none;
+ padding: 10px;
+ border: 1px solid #e5e5e5;
+ border-radius: 4px;
+ display: inline-block;
+}
+
+#menu a:hover,
+#menu a:active,
+#menu a:focus {
+ color: #333333;
+ text-decoration: none;
+}
+
+/* ====================================================================
+ REQUEST INFO SECTION
+ ==================================================================== */
+#requestinfo {
+ background-color: #f5f5f5;
+ border-bottom: 1px solid #e5e5e5;
+ border-top: 1px solid #e5e5e5;
+ margin-bottom: 20px;
+ padding: 10px;
+}
+
+#requestinfo input,
+#requestinfo button {
+ font-size: 14px;
+ font-weight: bold;
+ margin-right: 10px;
+ text-decoration: none;
+ padding: 8px 12px;
+}
+
+/* ====================================================================
+ MISC BUTTONS SECTION
+ ==================================================================== */
+#miscbuttons {
+ background-color: #f5f5f5;
+ border-bottom: 1px solid #e5e5e5;
+ border-top: 1px solid #e5e5e5;
+ margin-bottom: 20px;
+ padding: 10px;
+}
+
+#miscbuttons button,
+#miscbuttons select {
+ font-size: 14px;
+ font-weight: bold;
+ margin-right: 10px;
+ text-decoration: none;
+ padding: 8px 12px;
+}
+
+/* ====================================================================
+ NODE SEARCH TABLE
+ ==================================================================== */
+.nodesearch {
+ border-collapse: collapse;
+ border-spacing: 1px;
+ width: 100%;
+ border: 1px solid #ddd;
+}
+
+.nodesearch th,
+.nodesearch td {
+ text-align: left;
+ padding: 8px;
+}
+
+.nodesearch tr:nth-child(even) {
+ background-color: #f2f2f2;
+}
+
+.nodesearch tr:hover {
+ background-color: #ddd;
+}
+
+/* Row color coding based on test results */
+.nodesearch tr.fail {
+ background-color: #ffcccc;
+}
+
+.nodesearch tr.null {
+ background-color: #ffffcc;
+}
+
+.nodesearch tr.jobfilter {
+ border: 1px dashed red;
+}
+
+.nodesearch th {
+ background-color: #4CAF50;
+ color: white;
+}
+
+/* ====================================================================
+ NODE INFO DISPLAY
+ ==================================================================== */
+#nodeinfo {
+ line-height: 20px;
+ white-space: pre-wrap;
+}
+
+/* ====================================================================
+ MODAL DIALOG
+ ==================================================================== */
+#modal {
+ display: none;
+ position: fixed;
+ z-index: 1000;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ background-color: rgba(0, 0, 0, 0.4);
+}
+
+.modal-content {
+ background-color: #fefefe;
+ margin: 15% auto;
+ padding: 20px;
+ border: 1px solid #888;
+ width: 80%;
+ max-width: 500px;
+ border-radius: 4px;
+}
+
+.close {
+ color: #aaaaaa;
+ float: right;
+ font-size: 28px;
+ font-weight: bold;
+ cursor: pointer;
+}
+
+.close:hover,
+.close:focus {
+ color: #000;
+ text-decoration: none;
+}
+
+/* ====================================================================
+ ERROR MESSAGE
+ ==================================================================== */
+.error-message {
+ background-color: #ffcccc;
+ border: 1px solid #ff0000;
+ padding: 10px;
+ margin: 10px 0;
+ border-radius: 4px;
+ color: #cc0000;
+}
+
+/* ====================================================================
+ TREES SECTION
+ ==================================================================== */
+#treeselector {
+ background-color: #f5f5f5;
+ border-bottom: 1px solid #e5e5e5;
+ border-top: 1px solid #e5e5e5;
+ margin-bottom: 20px;
+ padding: 15px;
+}
+
+#treeselector select,
+#treeselector input {
+ font-size: 14px;
+ margin-right: 10px;
+ padding: 8px 12px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+}
+
+#treeselector button {
+ font-size: 14px;
+ font-weight: bold;
+ padding: 8px 16px;
+ background-color: #4CAF50;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+#treeselector button:hover {
+ background-color: #45a049;
+}
+
+#treeselector label {
+ font-weight: bold;
+ margin-right: 5px;
+}
+
+/* Matrix table for kbuilds across commits */
+.kbuild-matrix {
+ border-collapse: collapse;
+ width: 100%;
+ border: 1px solid #ddd;
+ margin-top: 20px;
+ font-size: 13px;
+}
+
+.kbuild-matrix th,
+.kbuild-matrix td {
+ border: 1px solid #ddd;
+ padding: 8px;
+ text-align: center;
+}
+
+.kbuild-matrix th {
+ background-color: #4CAF50;
+ color: white;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+}
+
+/* First column (kbuild names) should be sticky and left-aligned */
+.kbuild-matrix td:first-child,
+.kbuild-matrix th:first-child {
+ text-align: left;
+ position: sticky;
+ left: 0;
+ background-color: #f5f5f5;
+ font-weight: bold;
+ z-index: 5;
+}
+
+.kbuild-matrix th:first-child {
+ background-color: #4CAF50;
+ z-index: 15;
+}
+
+/* Build status cells */
+.kbuild-matrix .build-cell {
+ cursor: pointer;
+ min-width: 80px;
+ height: 30px;
+ vertical-align: middle;
+}
+
+.kbuild-matrix .build-cell:hover {
+ opacity: 0.8;
+ transform: scale(1.05);
+}
+
+/* Status colors */
+.kbuild-matrix .build-pass {
+ background-color: #90EE90;
+ color: #006400;
+}
+
+.kbuild-matrix .build-fail {
+ background-color: #FFB6C1;
+ color: #8B0000;
+}
+
+.kbuild-matrix .build-running {
+ background-color: #FFE4B5;
+ color: #FF8C00;
+}
+
+.kbuild-matrix .build-none {
+ background-color: #F0F0F0;
+ color: #808080;
+}
+
+/* Commit headers - rotate text for better space usage */
+.kbuild-matrix .commit-header {
+ writing-mode: vertical-rl;
+ text-orientation: mixed;
+ min-width: 100px;
+ max-width: 100px;
+ padding: 10px 5px;
+ font-family: monospace;
+ font-size: 12px;
+}
+
+/* Loading spinner */
+.loading-spinner {
+ display: inline-block;
+ margin-left: 10px;
+ width: 20px;
+ height: 20px;
+ border: 3px solid #f3f3f3;
+ border-top: 3px solid #4CAF50;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+ 0% { transform: rotate(0deg); }
+ 100% { transform: rotate(360deg); }
+}
diff --git a/api/static/js/viewer.js b/api/static/js/viewer.js
new file mode 100644
index 00000000..a1ca6f39
--- /dev/null
+++ b/api/static/js/viewer.js
@@ -0,0 +1,1172 @@
+
+/* ====================================================================
+ VIEWER APPLICATION - MAIN MODULE
+
+ This module encapsulates all functionality for the Maestro API Viewer.
+ It provides a web interface for browsing and searching KernelCI nodes.
+ ==================================================================== */
+
+/**
+ * Main application namespace to avoid polluting global scope
+ */
+const ViewerApp = (() => {
+ // ================================================================
+ // CONFIGURATION
+ // ================================================================
+
+ /**
+ * Application configuration object
+ */
+ const CONFIG = {
+ // Default search result limit
+ SEARCH_LIMIT: 250,
+
+ // Date ranges for quick searches
+ WEEK_AGO_DAYS: 7,
+ DAY_AGO_DAYS: 1,
+
+ // LAVA instance URL mappings
+ LAVA_URLS: {
+ 'lava-baylibre': 'lava.baylibre.com',
+ 'lava-broonie': 'lava.sirena.org.uk',
+ 'lava-cip': 'lava.ciplatform.org',
+ 'lava-collabora': 'lava.collabora.dev',
+ 'lava-collabora-early-access': 'staging.lava.collabora.dev',
+ 'lava-collabora-staging': 'staging.lava.collabora.dev',
+ 'lava-qualcomm': 'lava.infra.foundries.io',
+ },
+
+ // Search operators mapping
+ OPERATORS: {
+ '>': '__gt=',
+ '<': '__lt=',
+ '>=': '__gte=',
+ '<=': '__lte=',
+ '!=': '__ne=',
+ '=': '=',
+ },
+
+ // Table columns for search results
+ TABLE_COLUMNS: ['id', 'kind', 'name', 'platform', 'state', 'result', 'created'],
+ };
+
+ // ================================================================
+ // STATE MANAGEMENT
+ // ================================================================
+
+ /**
+ * Application state
+ */
+ const state = {
+ pageBaseUrl: '',
+ apiUrl: '',
+ weekAgoString: '',
+ dayAgoString: '',
+ };
+
+ /**
+ * Main menu configuration
+ */
+ let mainMenu = [];
+
+ // ================================================================
+ // INITIALIZATION
+ // ================================================================
+
+ /**
+ * Initialize the application
+ * Sets up URLs, date ranges, and renders the initial UI
+ */
+ function init() {
+ try {
+ // Initialize URLs
+ const url = window.location.href;
+ state.pageBaseUrl = url.split('?')[0];
+ state.apiUrl = state.pageBaseUrl.replace('/viewer', '');
+
+ // Calculate date ranges for quick searches
+ const weekAgo = new Date();
+ weekAgo.setDate(weekAgo.getDate() - CONFIG.WEEK_AGO_DAYS);
+ state.weekAgoString = weekAgo.toISOString().split('.')[0];
+
+ const dayAgo = new Date();
+ dayAgo.setDate(dayAgo.getDate() - CONFIG.DAY_AGO_DAYS);
+ state.dayAgoString = dayAgo.toISOString().split('.')[0];
+
+ // Configure main menu
+ mainMenu = [
+ {
+ name: 'Home',
+ suffix: '',
+ },
+ {
+ name: 'Node',
+ suffix: '?node_id=',
+ },
+ {
+ name: 'Search',
+ suffix: '?search=',
+ },
+ {
+ name: 'Trees',
+ suffix: '?view=trees',
+ },
+ {
+ name: 'Last week Checkouts',
+ suffix: `?search=kind%3Dcheckout&search=created%3E${state.weekAgoString}`,
+ },
+ {
+ name: 'Last 24h Checkouts',
+ suffix: `?search=kind%3Dcheckout&search=created%3E${state.dayAgoString}`,
+ },
+ ];
+
+ // Render the menu
+ displayMenu();
+
+ // Parse URL parameters if present
+ if (url.indexOf('?') !== -1) {
+ parseParameters(url);
+ }
+ } catch (error) {
+ handleError('Failed to initialize application', error);
+ }
+ }
+
+ // ================================================================
+ // UI RENDERING
+ // ================================================================
+
+ /**
+ * Display the main navigation menu
+ */
+ function displayMenu() {
+ const menu = document.getElementById('menu');
+ const menuHtml = mainMenu.map(item =>
+ ``
+ ).join(' ');
+
+ menu.innerHTML = menuHtml;
+
+ // Attach event listeners to menu links
+ const links = document.getElementsByClassName('menulink');
+ Array.from(links).forEach(link => {
+ link.addEventListener('click', handleMenuClick);
+ });
+ }
+
+ /**
+ * Clear all content divs
+ */
+ function clearDivs() {
+ const divIds = ['nodeinfo', 'requestinfo', 'miscbuttons', 'nodesearchdiv', 'treeselector'];
+ divIds.forEach(id => {
+ const div = document.getElementById(id);
+ div.innerHTML = '';
+ div.style.display = 'none';
+ });
+ }
+
+ /**
+ * Display error message to user
+ * @param {string} message - User-friendly error message
+ * @param {Error} error - Error object for console logging
+ */
+ function showError(message) {
+ const errorDiv = document.createElement('div');
+ errorDiv.className = 'error-message';
+ errorDiv.textContent = message;
+ document.body.insertBefore(errorDiv, document.getElementById('menu').nextSibling);
+
+ // Auto-remove after 5 seconds
+ setTimeout(() => errorDiv.remove(), 5000);
+ }
+
+ /**
+ * Show modal dialog with message
+ * @param {string} message - Message to display
+ */
+ function showModal(message) {
+ const modal = document.getElementById('modal');
+ const modalContent = document.getElementById('modalcontent');
+ modalContent.textContent = message;
+ modal.style.display = 'block';
+
+ // Setup close handlers
+ const closeBtn = document.getElementsByClassName('close')[0];
+ closeBtn.onclick = () => modal.style.display = 'none';
+
+ window.onclick = (event) => {
+ if (event.target === modal) {
+ modal.style.display = 'none';
+ }
+ };
+ }
+
+ /**
+ * Hide modal dialog
+ */
+ function hideModal() {
+ const modal = document.getElementById('modal');
+ modal.style.display = 'none';
+ }
+
+ // ================================================================
+ // NODE DISPLAY
+ // ================================================================
+
+ /**
+ * Display detailed information for a single node
+ * @param {string} nodeId - The node ID to display
+ */
+ async function displayNode(nodeId) {
+ try {
+ // Hide request info section
+ const requestInfo = document.getElementById('requestinfo');
+ requestInfo.innerHTML = '';
+ requestInfo.style.display = 'none';
+
+ // Fetch node data from API
+ const url = `${state.apiUrl}/latest/node/${nodeId}`;
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ const rawText = JSON.stringify(data);
+
+ // Display action buttons
+ addMiscButtons(data, rawText);
+
+ // Display formatted JSON
+ const nodeInfo = document.getElementById('nodeinfo');
+ nodeInfo.style.display = 'block';
+ nodeInfo.innerHTML = `
${formatJson(rawText)}`;
+
+ } catch (error) {
+ handleError(`Failed to load node ${nodeId}`, error);
+ }
+ }
+
+ /**
+ * Add action buttons for a node (Parent, Children, Download, LAVA Job)
+ * @param {Object} data - Node data object
+ * @param {string} raw - Raw JSON string
+ */
+ function addMiscButtons(data, raw) {
+ const miscButtons = document.getElementById('miscbuttons');
+ miscButtons.style.display = 'block';
+
+ const buttons = [];
+
+ // Parent button
+ if (data.parent) {
+ buttons.push(``);
+ }
+
+ // Children button
+ const childCondition = encodeURIComponent(`parent=${data.id}`);
+ buttons.push(``);
+
+ // Artifacts dropdown
+ buttons.push(createArtifactsDropdown(data));
+
+ // Download button
+ buttons.push('');
+
+ // LAVA job button (if applicable)
+ if (data.data?.runtime?.startsWith('lava') && data.data?.job_id) {
+ const lavaUrl = CONFIG.LAVA_URLS[data.data.runtime];
+ if (lavaUrl) {
+ const jobUrl = `https://${lavaUrl}/scheduler/job/${data.data.job_id}`;
+ buttons.push(``);
+ }
+ }
+
+ // Node size info
+ buttons.push(`Node size: ${raw.length} bytes`);
+
+ miscButtons.innerHTML = buttons.join('');
+
+ // Attach event listeners
+ attachMiscButtonListeners();
+ }
+
+ /**
+ * Create artifacts dropdown HTML
+ * @param {Object} data - Node data object
+ * @returns {string} HTML string for artifacts dropdown
+ */
+ function createArtifactsDropdown(data) {
+ const options = [``];
+
+ if (data.artifacts) {
+ Object.entries(data.artifacts).forEach(([name, uri]) => {
+ options.push(``);
+ });
+ }
+
+ return ``;
+ }
+
+ /**
+ * Attach event listeners to misc buttons
+ */
+ function attachMiscButtonListeners() {
+ // Misc buttons (navigation)
+ const miscLinks = document.getElementsByClassName('misc');
+ Array.from(miscLinks).forEach(link => {
+ link.addEventListener('click', handleMiscClick);
+ });
+
+ // Download button
+ const downloadButtons = document.getElementsByClassName('download');
+ Array.from(downloadButtons).forEach(button => {
+ button.addEventListener('click', (event) => {
+ event.preventDefault();
+ const url = document.getElementById('artifacts').value;
+ window.open(url, '_blank');
+ });
+ });
+ }
+
+ // ================================================================
+ // SEARCH FUNCTIONALITY
+ // ================================================================
+
+ /**
+ * Process and execute a search query
+ * @param {Array} conditions - Array of search conditions
+ */
+ async function processSearch(conditions) {
+ try {
+ // Build search URL
+ const conditionParams = conditions
+ .map(cond => convertCondition(decodeURIComponent(cond)))
+ .join('&');
+
+ const url = `${state.apiUrl}/latest/nodes?${conditionParams}&limit=${CONFIG.SEARCH_LIMIT}`;
+
+ console.log('Search URL:', url);
+
+ // Show loading modal
+ showModal('Loading search results...');
+
+ // Fetch search results
+ const response = await fetch(url);
+
+ if (!response.ok) {
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ hideModal();
+ displaySearchResults(data);
+
+ } catch (error) {
+ hideModal();
+ handleError('Search failed', error);
+ }
+ }
+
+ /**
+ * Convert user-friendly search condition to API format
+ * @param {string} condition - Search condition (e.g., "created>2024-01-01")
+ * @returns {string} Converted condition for API
+ */
+ function convertCondition(condition) {
+ // Pattern: key operator value (e.g., "created>=2024-01-01")
+ const pattern = /^([.a-zA-Z0-9_-]+)([<>!=]+)(.*)/;
+ const match = pattern.exec(condition);
+
+ if (!match) {
+ console.warn('Condition does not match pattern:', condition);
+ return condition;
+ }
+
+ const [, key, operator, value] = match;
+ const apiOperator = CONFIG.OPERATORS[operator] || operator;
+
+ console.log(`Converted: ${key}${operator}${value} -> ${key}${apiOperator}${value}`);
+ return `${key}${apiOperator}${value}`;
+ }
+
+ /**
+ * Display search results in a table
+ * @param {Object} data - Search results data
+ */
+ function displaySearchResults(data) {
+ clearDivs();
+
+ const searchDiv = document.getElementById('nodesearchdiv');
+ searchDiv.style.display = 'block';
+
+ // Determine table columns based on data
+ const columns = [...CONFIG.TABLE_COLUMNS];
+ if (data.items.length > 0 && data.items[0].data?.kernel_revision) {
+ columns.push('tree', 'branch', 'commit');
+ }
+
+ // Sort results by creation date (newest first)
+ data.items.sort((a, b) => new Date(b.created) - new Date(a.created));
+
+ // Build table HTML
+ const tableHtml = `
+
+ ${createTableHeader(columns)}
+ ${createTableRows(data.items)}
+
+ `;
+
+ searchDiv.innerHTML = tableHtml;
+ }
+
+ /**
+ * Create table header HTML
+ * @param {Array} columns - Column names
+ * @returns {string} Table header HTML
+ */
+ function createTableHeader(columns) {
+ const headers = columns.map(col => `${col} | `).join('');
+ return `${headers}
`;
+ }
+
+ /**
+ * Create table rows HTML
+ * @param {Array