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})();