SkyPilot Dashboard Auto Refresh Logs

A new userscript

Size

25.7 KB

Version

1.1.5

Created

Nov 27, 2025

Updated

15 days ago

1// ==UserScript==
2// @name		SkyPilot Dashboard Auto Refresh Logs
3// @description		A new userscript
4// @version		1.1.5
5// @match		http://localhost/*
6// @icon		http://localhost:46580/dashboard/favicon.ico
7// ==/UserScript==
8(function() {
9    'use strict';
10    
11    let autoRefreshInterval = null;
12    let refreshIntervalSeconds = 30; // Default 30 seconds
13    let isAutoRefreshEnabled = false;
14    
15    // Debounce function to prevent excessive calls
16    function debounce(func, wait) {
17        let timeout;
18        return function executedFunction(...args) {
19            const later = () => {
20                clearTimeout(timeout);
21                func(...args);
22            };
23            clearTimeout(timeout);
24            timeout = setTimeout(later, wait);
25        };
26    }
27    
28    // Function to scroll to bottom of logs
29    function scrollToBottomOfLogs() {
30        const logsContainer = document.querySelector('#logs .max-h-96.overflow-y-auto');
31        if (logsContainer) {
32            logsContainer.scrollTop = logsContainer.scrollHeight;
33            console.log('Auto-scrolled to bottom of logs');
34        }
35    }
36    
37    // Function to fetch logs via XHR in the background
38    async function fetchLogsInBackground() {
39        try {
40            // Extract cluster ID and job ID from current URL
41            const urlParts = window.location.pathname.split('/');
42            const clusterId = urlParts[urlParts.length - 2];
43            const jobId = urlParts[urlParts.length - 1];
44            
45            // Use the correct POST endpoint for logs
46            const logsApiUrl = `${window.location.origin}/internal/dashboard/logs`;
47            
48            const requestBody = {
49                follow: false,
50                cluster_name: clusterId,
51                job_id: jobId,
52                tail: 10000,
53                override_skypilot_config: {
54                    active_workspace: 'default'
55                }
56            };
57            
58            console.log('Fetching logs from:', logsApiUrl, 'with body:', requestBody);
59            
60            const response = await GM.xmlhttpRequest({
61                method: 'POST',
62                url: logsApiUrl,
63                headers: {
64                    'Accept': 'application/json',
65                    'Content-Type': 'application/json',
66                    'Cache-Control': 'no-cache'
67                },
68                data: JSON.stringify(requestBody)
69            });
70            
71            if (response.status === 200) {
72                const logsData = JSON.parse(response.responseText);
73                console.log('Logs data received from API:', logsData);
74                
75                // Update the logs container with the new data
76                updateLogsDisplay(logsData);
77                
78                // Auto-scroll to bottom after update
79                setTimeout(scrollToBottomOfLogs, 100);
80            } else {
81                console.error('Failed to fetch logs:', response.status, response.responseText);
82                // Fallback to page refresh method
83                await fallbackRefreshMethod();
84            }
85        } catch (error) {
86            console.error('Error fetching logs via API:', error);
87            // Fallback to page refresh method
88            await fallbackRefreshMethod();
89        }
90    }
91    
92    // Function to update logs display with API data
93    function updateLogsDisplay(logsData) {
94        const currentLogsContainer = document.querySelector('#logs .logs-container');
95        if (!currentLogsContainer) {
96            console.error('Logs container not found');
97            return;
98        }
99        
100        // Clear existing logs
101        currentLogsContainer.innerHTML = '';
102        
103        // Handle different response formats
104        let logLines = [];
105        
106        if (logsData && logsData.logs && Array.isArray(logsData.logs)) {
107            logLines = logsData.logs;
108        } else if (logsData && typeof logsData.logs === 'string') {
109            logLines = logsData.logs.split('\n');
110        } else if (typeof logsData === 'string') {
111            logLines = logsData.split('\n');
112        } else if (Array.isArray(logsData)) {
113            logLines = logsData;
114        }
115        
116        // Add new log entries
117        logLines.forEach(logEntry => {
118            if (logEntry && (typeof logEntry === 'string' ? logEntry.trim() : true)) {
119                const logLine = document.createElement('span');
120                logLine.className = 'log-line';
121                
122                const message = document.createElement('span');
123                message.className = 'message';
124                message.textContent = typeof logEntry === 'object' ? (logEntry.message || logEntry.text || JSON.stringify(logEntry)) : logEntry;
125                
126                logLine.appendChild(message);
127                currentLogsContainer.appendChild(logLine);
128            }
129        });
130        
131        console.log('Logs display updated via API');
132    }
133    
134    // Fallback method using page refresh
135    async function fallbackRefreshMethod() {
136        try {
137            const currentUrl = window.location.href;
138            const response = await GM.xmlhttpRequest({
139                method: 'GET',
140                url: currentUrl,
141                headers: {
142                    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
143                    'Cache-Control': 'no-cache'
144                }
145            });
146            
147            if (response.status === 200) {
148                // Parse the response HTML
149                const parser = new DOMParser();
150                const doc = parser.parseFromString(response.responseText, 'text/html');
151                
152                // Extract the logs container from the response
153                const newLogsContainer = doc.querySelector('#logs .logs-container');
154                const currentLogsContainer = document.querySelector('#logs .logs-container');
155                
156                if (newLogsContainer && currentLogsContainer) {
157                    // Update the logs content
158                    currentLogsContainer.innerHTML = newLogsContainer.innerHTML;
159                    console.log('Logs updated via fallback page refresh');
160                    
161                    // Auto-scroll to bottom after update
162                    setTimeout(scrollToBottomOfLogs, 100);
163                } else {
164                    console.warn('Could not find logs container in response');
165                }
166            } else {
167                console.error('Failed to fetch page:', response.status);
168            }
169        } catch (error) {
170            console.error('Error in fallback refresh method:', error);
171        }
172    }
173    
174    // Function to click the refresh button (fallback method)
175    function refreshLogs() {
176        // Try to find refresh button by looking for buttons containing "Refresh" text
177        const buttons = document.querySelectorAll('button');
178        let refreshButton = null;
179        
180        for (const button of buttons) {
181            if (button.textContent.includes('Refresh')) {
182                refreshButton = button;
183                break;
184            }
185        }
186        
187        if (refreshButton) {
188            refreshButton.click();
189            console.log('Logs refreshed');
190            // Scroll to bottom after a short delay to allow content to load
191            setTimeout(scrollToBottomOfLogs, 1000);
192        } else {
193            console.error('Refresh button not found');
194        }
195    }
196    
197    // Function to start auto refresh
198    async function startAutoRefresh() {
199        if (autoRefreshInterval) {
200            clearInterval(autoRefreshInterval);
201        }
202        
203        isAutoRefreshEnabled = true;
204        await GM.setValue('autoRefreshEnabled', true);
205        await GM.setValue('refreshInterval', refreshIntervalSeconds);
206        
207        autoRefreshInterval = setInterval(() => {
208            if (isAutoRefreshEnabled) {
209                fetchLogsInBackground();
210            }
211        }, refreshIntervalSeconds * 1000);
212        
213        console.log(`Auto refresh started with ${refreshIntervalSeconds}s interval`);
214        updateControlPanel();
215    }
216    
217    // Function to stop auto refresh
218    async function stopAutoRefresh() {
219        if (autoRefreshInterval) {
220            clearInterval(autoRefreshInterval);
221            autoRefreshInterval = null;
222        }
223        
224        isAutoRefreshEnabled = false;
225        await GM.setValue('autoRefreshEnabled', false);
226        
227        console.log('Auto refresh stopped');
228        updateControlPanel();
229    }
230    
231    // Function to update the control panel UI
232    function updateControlPanel() {
233        const statusElement = document.getElementById('auto-refresh-status');
234        const toggleButton = document.getElementById('auto-refresh-toggle');
235        
236        if (statusElement && toggleButton) {
237            if (isAutoRefreshEnabled) {
238                statusElement.textContent = `Auto refresh: ON (${refreshIntervalSeconds}s)`;
239                statusElement.style.color = '#10b981'; // Green
240                toggleButton.textContent = 'Stop Auto Refresh';
241                toggleButton.style.backgroundColor = '#ef4444'; // Red
242            } else {
243                statusElement.textContent = 'Auto refresh: OFF';
244                statusElement.style.color = '#6b7280'; // Gray
245                toggleButton.textContent = 'Start Auto Refresh';
246                toggleButton.style.backgroundColor = '#10b981'; // Green
247            }
248        }
249    }
250    
251    // Function to create the control panel
252    function createControlPanel() {
253        // Check if control panel already exists
254        if (document.getElementById('auto-refresh-control-panel')) {
255            return;
256        }
257        
258        const logsSection = document.getElementById('logs');
259        if (!logsSection) {
260            console.error('Logs section not found');
261            return;
262        }
263        
264        // Create control panel container
265        const controlPanel = document.createElement('div');
266        controlPanel.id = 'auto-refresh-control-panel';
267        controlPanel.style.cssText = `
268            background: #f8fafc;
269            border: 1px solid #e2e8f0;
270            border-radius: 8px;
271            padding: 12px;
272            margin-bottom: 16px;
273            display: flex;
274            align-items: center;
275            gap: 12px;
276            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
277        `;
278        
279        // Status display
280        const statusElement = document.createElement('span');
281        statusElement.id = 'auto-refresh-status';
282        statusElement.style.cssText = `
283            font-size: 14px;
284            font-weight: 500;
285        `;
286        
287        // Interval input
288        const intervalLabel = document.createElement('label');
289        intervalLabel.textContent = 'Interval (seconds):';
290        intervalLabel.style.cssText = `
291            font-size: 14px;
292            color: #374151;
293        `;
294        
295        const intervalInput = document.createElement('input');
296        intervalInput.type = 'number';
297        intervalInput.min = '5';
298        intervalInput.max = '300';
299        intervalInput.value = refreshIntervalSeconds;
300        intervalInput.style.cssText = `
301            width: 80px;
302            padding: 4px 8px;
303            border: 1px solid #d1d5db;
304            border-radius: 4px;
305            font-size: 14px;
306        `;
307        
308        intervalInput.addEventListener('change', async (e) => {
309            const newInterval = parseInt(e.target.value);
310            if (newInterval >= 5 && newInterval <= 300) {
311                refreshIntervalSeconds = newInterval;
312                await GM.setValue('refreshInterval', refreshIntervalSeconds);
313                
314                // Restart auto refresh if it's currently running
315                if (isAutoRefreshEnabled) {
316                    await startAutoRefresh();
317                }
318                console.log(`Refresh interval updated to ${refreshIntervalSeconds}s`);
319            }
320        });
321        
322        // Toggle button
323        const toggleButton = document.createElement('button');
324        toggleButton.id = 'auto-refresh-toggle';
325        toggleButton.style.cssText = `
326            padding: 6px 12px;
327            border: none;
328            border-radius: 4px;
329            color: white;
330            font-size: 14px;
331            font-weight: 500;
332            cursor: pointer;
333            transition: opacity 0.2s;
334        `;
335        
336        toggleButton.addEventListener('click', async () => {
337            if (isAutoRefreshEnabled) {
338                await stopAutoRefresh();
339            } else {
340                await startAutoRefresh();
341            }
342        });
343        
344        toggleButton.addEventListener('mouseenter', () => {
345            toggleButton.style.opacity = '0.8';
346        });
347        
348        toggleButton.addEventListener('mouseleave', () => {
349            toggleButton.style.opacity = '1';
350        });
351        
352        // Manual refresh button
353        const manualRefreshButton = document.createElement('button');
354        manualRefreshButton.textContent = 'Refresh Now';
355        manualRefreshButton.style.cssText = `
356            padding: 6px 12px;
357            border: 1px solid #3b82f6;
358            border-radius: 4px;
359            background: white;
360            color: #3b82f6;
361            font-size: 14px;
362            font-weight: 500;
363            cursor: pointer;
364            transition: all 0.2s;
365        `;
366        
367        manualRefreshButton.addEventListener('click', () => {
368            fetchLogsInBackground();
369        });
370        
371        manualRefreshButton.addEventListener('mouseenter', () => {
372            manualRefreshButton.style.backgroundColor = '#3b82f6';
373            manualRefreshButton.style.color = 'white';
374        });
375        
376        manualRefreshButton.addEventListener('mouseleave', () => {
377            manualRefreshButton.style.backgroundColor = 'white';
378            manualRefreshButton.style.color = '#3b82f6';
379        });
380        
381        // Scroll to bottom button
382        const scrollButton = document.createElement('button');
383        scrollButton.textContent = 'Scroll to Bottom';
384        scrollButton.style.cssText = `
385            padding: 6px 12px;
386            border: 1px solid #6b7280;
387            border-radius: 4px;
388            background: white;
389            color: #6b7280;
390            font-size: 14px;
391            font-weight: 500;
392            cursor: pointer;
393            transition: all 0.2s;
394        `;
395        
396        scrollButton.addEventListener('click', scrollToBottomOfLogs);
397        
398        scrollButton.addEventListener('mouseenter', () => {
399            scrollButton.style.backgroundColor = '#6b7280';
400            scrollButton.style.color = 'white';
401        });
402        
403        scrollButton.addEventListener('mouseleave', () => {
404            scrollButton.style.backgroundColor = 'white';
405            scrollButton.style.color = '#6b7280';
406        });
407        
408        // Hide/Show Details button
409        const detailsToggleButton = document.createElement('button');
410        detailsToggleButton.textContent = 'Hide Details';
411        detailsToggleButton.style.cssText = `
412            padding: 6px 12px;
413            border: 1px solid #f59e0b;
414            border-radius: 4px;
415            background: white;
416            color: #f59e0b;
417            font-size: 14px;
418            font-weight: 500;
419            cursor: pointer;
420            transition: all 0.2s;
421        `;
422        
423        detailsToggleButton.addEventListener('click', async () => {
424            await toggleDetailsPanel();
425            const isHidden = await GM.getValue('detailsHidden', false);
426            detailsToggleButton.textContent = isHidden ? 'Show Details' : 'Hide Details';
427            
428            // Create or remove compact status based on visibility
429            if (isHidden) {
430                createCompactStatus();
431            } else {
432                const compactStatus = document.getElementById('compact-status-display');
433                if (compactStatus) {
434                    compactStatus.remove();
435                }
436            }
437        });
438        
439        detailsToggleButton.addEventListener('mouseenter', () => {
440            detailsToggleButton.style.backgroundColor = '#f59e0b';
441            detailsToggleButton.style.color = 'white';
442        });
443        
444        detailsToggleButton.addEventListener('mouseleave', () => {
445            detailsToggleButton.style.backgroundColor = 'white';
446            detailsToggleButton.style.color = '#f59e0b';
447        });
448        
449        // Assemble control panel
450        controlPanel.appendChild(statusElement);
451        controlPanel.appendChild(intervalLabel);
452        controlPanel.appendChild(intervalInput);
453        controlPanel.appendChild(toggleButton);
454        controlPanel.appendChild(manualRefreshButton);
455        controlPanel.appendChild(scrollButton);
456        controlPanel.appendChild(detailsToggleButton);
457        
458        // Insert control panel before logs section
459        logsSection.parentNode.insertBefore(controlPanel, logsSection);
460        
461        // Update initial state
462        updateControlPanel();
463        
464        console.log('Auto refresh control panel created');
465    }
466    
467    // Function to load saved settings
468    async function loadSettings() {
469        try {
470            const savedEnabled = await GM.getValue('autoRefreshEnabled', false);
471            const savedInterval = await GM.getValue('refreshInterval', 30);
472            
473            isAutoRefreshEnabled = savedEnabled;
474            refreshIntervalSeconds = savedInterval;
475            
476            console.log(`Loaded settings: enabled=${isAutoRefreshEnabled}, interval=${refreshIntervalSeconds}s`);
477            
478            // Start auto refresh if it was previously enabled
479            if (isAutoRefreshEnabled) {
480                await startAutoRefresh();
481            }
482        } catch (error) {
483            console.error('Error loading settings:', error);
484        }
485    }
486    
487    // Function to observe DOM changes and recreate control panel if needed
488    const debouncedCreateControlPanel = debounce(createControlPanel, 1000);
489    
490    function observePageChanges() {
491        const observer = new MutationObserver(debouncedCreateControlPanel);
492        observer.observe(document.body, {
493            childList: true,
494            subtree: true
495        });
496    }
497    
498    // Function to toggle details panel visibility
499    async function toggleDetailsPanel() {
500        const detailsSection = document.getElementById('details');
501        const isHidden = await GM.getValue('detailsHidden', false);
502        
503        if (detailsSection) {
504            if (isHidden) {
505                detailsSection.style.display = 'block';
506                await GM.setValue('detailsHidden', false);
507                console.log('Details panel shown');
508            } else {
509                detailsSection.style.display = 'none';
510                await GM.setValue('detailsHidden', true);
511                console.log('Details panel hidden');
512            }
513        }
514        
515        updateControlPanel();
516    }
517    
518    // Function to create compact status display
519    function createCompactStatus() {
520        // Check if compact status already exists
521        if (document.getElementById('compact-status-display')) {
522            return;
523        }
524        
525        // Extract status information from the details section
526        const detailsSection = document.getElementById('details');
527        if (!detailsSection) return;
528        
529        let jobId = '';
530        let jobName = '';
531        let status = '';
532        let cluster = '';
533        
534        // Extract job information using more reliable selectors
535        const detailsGrid = detailsSection.querySelector('.grid');
536        if (detailsGrid) {
537            const gridItems = detailsGrid.querySelectorAll('div > div');
538            
539            for (let i = 0; i < gridItems.length; i += 2) {
540                const label = gridItems[i];
541                const value = gridItems[i + 1];
542                
543                if (label && value) {
544                    const labelText = label.textContent.trim();
545                    const valueText = value.textContent.trim();
546                    
547                    if (labelText === 'Job ID') {
548                        jobId = valueText;
549                    } else if (labelText === 'Job Name') {
550                        jobName = valueText;
551                    } else if (labelText === 'Status') {
552                        const statusSpan = value.querySelector('span');
553                        if (statusSpan) {
554                            status = statusSpan.textContent.trim();
555                        }
556                    } else if (labelText === 'Cluster') {
557                        const clusterLink = value.querySelector('a');
558                        if (clusterLink) {
559                            cluster = clusterLink.textContent.trim();
560                        }
561                    }
562                }
563            }
564        }
565        
566        // Create compact status display
567        const compactStatus = document.createElement('div');
568        compactStatus.id = 'compact-status-display';
569        compactStatus.style.cssText = `
570            background: #f8fafc;
571            border: 1px solid #e2e8f0;
572            border-radius: 8px;
573            padding: 8px 12px;
574            margin-bottom: 16px;
575            display: flex;
576            align-items: center;
577            gap: 16px;
578            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
579            font-size: 14px;
580        `;
581        
582        // Job info
583        const jobInfo = document.createElement('span');
584        jobInfo.style.cssText = `
585            font-weight: 500;
586            color: #374151;
587        `;
588        jobInfo.textContent = `Job ${jobId}: ${jobName}`;
589        
590        // Status badge
591        const statusBadge = document.createElement('span');
592        statusBadge.style.cssText = `
593            padding: 2px 8px;
594            border-radius: 12px;
595            font-size: 12px;
596            font-weight: 500;
597            ${status.toLowerCase() === 'running' ? 'background: #dcfce7; color: #166534;' : 
598        status.toLowerCase() === 'completed' ? 'background: #dbeafe; color: #1e40af;' :
599            status.toLowerCase() === 'failed' ? 'background: #fee2e2; color: #dc2626;' :
600                'background: #f3f4f6; color: #374151;'}
601        `;
602        statusBadge.textContent = status;
603        
604        // Cluster info
605        const clusterInfo = document.createElement('span');
606        clusterInfo.style.cssText = `
607            color: #6b7280;
608            font-size: 13px;
609        `;
610        clusterInfo.textContent = `Cluster: ${cluster}`;
611        
612        compactStatus.appendChild(jobInfo);
613        compactStatus.appendChild(statusBadge);
614        compactStatus.appendChild(clusterInfo);
615        
616        // Insert before logs section
617        const logsSection = document.getElementById('logs');
618        if (logsSection) {
619            logsSection.parentNode.insertBefore(compactStatus, logsSection);
620        }
621        
622        console.log('Compact status display created');
623    }
624    
625    // Function to update compact status display
626    function updateCompactStatus() {
627        const compactStatus = document.getElementById('compact-status-display');
628        if (!compactStatus) return;
629        
630        // Re-extract status information
631        const detailsSection = document.getElementById('details');
632        if (!detailsSection) return;
633        
634        const statusElement = detailsSection.querySelector('span[class*="bg-"]');
635        if (statusElement) {
636            const statusBadge = compactStatus.querySelector('span:nth-child(2)');
637            if (statusBadge) {
638                const status = statusElement.textContent.trim();
639                statusBadge.textContent = status;
640                statusBadge.style.cssText = `
641                    padding: 2px 8px;
642                    border-radius: 12px;
643                    font-size: 12px;
644                    font-weight: 500;
645                    ${status.toLowerCase() === 'running' ? 'background: #dcfce7; color: #166534;' : 
646        status.toLowerCase() === 'completed' ? 'background: #dbeafe; color: #1e40af;' :
647            status.toLowerCase() === 'failed' ? 'background: #fee2e2; color: #dc2626;' :
648                'background: #f3f4f6; color: #374151;'}
649                `;
650            }
651        }
652    }
653    
654    // Function to apply saved details panel visibility
655    async function applyDetailsVisibility() {
656        const isHidden = await GM.getValue('detailsHidden', false);
657        const detailsSection = document.getElementById('details');
658        
659        if (detailsSection && isHidden) {
660            detailsSection.style.display = 'none';
661            createCompactStatus();
662        }
663    }
664    
665    // Initialize the extension
666    async function init() {
667        console.log('SkyPilot Dashboard Auto Refresh Logs extension initialized');
668        
669        // Wait for page to load
670        if (document.readyState === 'loading') {
671            document.addEventListener('DOMContentLoaded', init);
672            return;
673        }
674        
675        // Load saved settings
676        await loadSettings();
677        
678        // Create control panel after a short delay to ensure DOM is ready
679        setTimeout(createControlPanel, 1000);
680        
681        // Apply saved details visibility after DOM is ready
682        setTimeout(applyDetailsVisibility, 1500);
683        
684        // Observe page changes to recreate control panel if needed
685        observePageChanges();
686        
687        // Auto-scroll to bottom when logs are updated (if auto refresh is enabled)
688        const logsContainer = document.querySelector('#logs .max-h-96.overflow-y-auto');
689        if (logsContainer) {
690            const logsObserver = new MutationObserver(debounce(() => {
691                if (isAutoRefreshEnabled) {
692                    scrollToBottomOfLogs();
693                }
694            }, 500));
695            
696            logsObserver.observe(logsContainer, {
697                childList: true,
698                subtree: true
699            });
700        }
701    }
702    
703    // Start the extension
704    init();
705})();
SkyPilot Dashboard Auto Refresh Logs | Robomonkey