Update performance dashboard for nightly tests (#18824)
This commit is contained in:
@@ -23,12 +23,12 @@ const metricTypes = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Chart.js default configuration for dark theme
|
// Chart.js default configuration for dark theme
|
||||||
Chart.defaults.color = '#8b949e';
|
Chart.defaults.color = '#94a3b8';
|
||||||
Chart.defaults.borderColor = '#30363d';
|
Chart.defaults.borderColor = '#1e293b';
|
||||||
|
|
||||||
const chartColors = [
|
const chartColors = [
|
||||||
'#58a6ff', '#3fb950', '#d29922', '#f85149', '#a371f7',
|
'#22d3ee', '#34d399', '#fbbf24', '#f87171', '#a78bfa',
|
||||||
'#79c0ff', '#56d364', '#e3b341', '#ff7b72', '#bc8cff'
|
'#67e8f9', '#6ee7b7', '#fcd34d', '#fca5a5', '#c4b5fd'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Initialize the dashboard
|
// Initialize the dashboard
|
||||||
@@ -53,7 +53,7 @@ async function init() {
|
|||||||
async function loadData() {
|
async function loadData() {
|
||||||
// Try local server API first (if running server.py)
|
// Try local server API first (if running server.py)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/metrics');
|
const response = await fetch('/api/metrics', { headers: getAuthHeaders() });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.length > 0 && data[0].results && data[0].results.length > 0) {
|
if (data.length > 0 && data[0].results && data[0].results.length > 0) {
|
||||||
@@ -726,12 +726,13 @@ function getChartOptions(yAxisLabel) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
backgroundColor: '#21262d',
|
backgroundColor: '#1a2332',
|
||||||
borderColor: '#30363d',
|
borderColor: 'rgba(148, 163, 184, 0.1)',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
titleFont: { size: 13 },
|
titleFont: { size: 13, family: "'DM Sans', sans-serif" },
|
||||||
bodyFont: { size: 12 },
|
bodyFont: { size: 12, family: "'JetBrains Mono', monospace" },
|
||||||
padding: 12
|
padding: 14,
|
||||||
|
cornerRadius: 8
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
@@ -744,7 +745,7 @@ function getChartOptions(yAxisLabel) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
color: '#21262d'
|
color: 'rgba(148, 163, 184, 0.06)'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
@@ -753,7 +754,7 @@ function getChartOptions(yAxisLabel) {
|
|||||||
text: yAxisLabel
|
text: yAxisLabel
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
color: '#21262d'
|
color: 'rgba(148, 163, 184, 0.06)'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -832,5 +833,109 @@ function formatNumber(num) {
|
|||||||
return num.toFixed(1);
|
return num.toFixed(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Authentication state
|
||||||
|
let authToken = sessionStorage.getItem('dashboard_auth_token') || null;
|
||||||
|
|
||||||
|
// Get auth headers for API requests
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const headers = {};
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if server requires authentication and show/hide login accordingly
|
||||||
|
async function checkAuthAndInit() {
|
||||||
|
const loginOverlay = document.getElementById('login-overlay');
|
||||||
|
const dashboardContainer = document.getElementById('dashboard-container');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth-check');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (!data.auth_required) {
|
||||||
|
// No auth required - skip login, show dashboard directly
|
||||||
|
loginOverlay.style.display = 'none';
|
||||||
|
dashboardContainer.style.display = 'block';
|
||||||
|
init();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Server not available (e.g. static hosting) - skip login
|
||||||
|
loginOverlay.style.display = 'none';
|
||||||
|
dashboardContainer.style.display = 'block';
|
||||||
|
init();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth is required - check if we have a valid token from a previous session
|
||||||
|
if (authToken) {
|
||||||
|
try {
|
||||||
|
const testResponse = await fetch('/api/metrics', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
if (testResponse.ok) {
|
||||||
|
loginOverlay.style.display = 'none';
|
||||||
|
dashboardContainer.style.display = 'block';
|
||||||
|
init();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Token invalid or expired
|
||||||
|
}
|
||||||
|
// Clear invalid token
|
||||||
|
authToken = null;
|
||||||
|
sessionStorage.removeItem('dashboard_auth_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show login form
|
||||||
|
loginOverlay.style.display = 'flex';
|
||||||
|
dashboardContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle login form submission
|
||||||
|
async function handleLogin(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const username = document.getElementById('login-username').value;
|
||||||
|
const password = document.getElementById('login-password').value;
|
||||||
|
const errorEl = document.getElementById('login-error');
|
||||||
|
const loginBtn = document.getElementById('login-btn');
|
||||||
|
|
||||||
|
errorEl.textContent = '';
|
||||||
|
loginBtn.disabled = true;
|
||||||
|
loginBtn.textContent = 'Signing in...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.token) {
|
||||||
|
authToken = data.token;
|
||||||
|
sessionStorage.setItem('dashboard_auth_token', authToken);
|
||||||
|
|
||||||
|
document.getElementById('login-overlay').style.display = 'none';
|
||||||
|
document.getElementById('dashboard-container').style.display = 'block';
|
||||||
|
init();
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = data.error || 'Invalid username or password';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorEl.textContent = 'Unable to connect to server';
|
||||||
|
} finally {
|
||||||
|
loginBtn.disabled = false;
|
||||||
|
loginBtn.textContent = 'Sign In';
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', checkAuthAndInit);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,19 @@ Usage:
|
|||||||
python server.py --port 8080
|
python server.py --port 8080
|
||||||
python server.py --host 0.0.0.0 # Allow external access
|
python server.py --host 0.0.0.0 # Allow external access
|
||||||
python server.py --fetch-on-start
|
python server.py --fetch-on-start
|
||||||
|
python server.py --username admin --password secret # Enable authentication
|
||||||
|
DASHBOARD_USERNAME=admin DASHBOARD_PASSWORD=secret python server.py # Via env vars
|
||||||
|
python server.py --refresh-interval 12 # Auto-refresh data every 12 hours
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
import http.server
|
import http.server
|
||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import secrets
|
||||||
import socketserver
|
import socketserver
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -44,6 +50,47 @@ metrics_cache = {
|
|||||||
CACHE_TTL = 300 # 5 minutes
|
CACHE_TTL = 300 # 5 minutes
|
||||||
REQUEST_TIMEOUT = 30 # seconds
|
REQUEST_TIMEOUT = 30 # seconds
|
||||||
|
|
||||||
|
# Authentication configuration (set via CLI flags)
|
||||||
|
auth_config = {
|
||||||
|
"enabled": False,
|
||||||
|
"username": None,
|
||||||
|
"password_hash": None, # SHA-256 hash of the password
|
||||||
|
"active_tokens": {}, # token -> expiry timestamp
|
||||||
|
}
|
||||||
|
auth_lock = threading.Lock()
|
||||||
|
AUTH_TOKEN_TTL = 3600 # 1 hour
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password):
|
||||||
|
"""Hash a password using SHA-256 for constant-time comparison."""
|
||||||
|
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def create_auth_token():
|
||||||
|
"""Create a new session token."""
|
||||||
|
token = secrets.token_hex(32)
|
||||||
|
with auth_lock:
|
||||||
|
# Clean up expired tokens
|
||||||
|
now = time.time()
|
||||||
|
auth_config["active_tokens"] = {
|
||||||
|
t: exp for t, exp in auth_config["active_tokens"].items() if exp > now
|
||||||
|
}
|
||||||
|
auth_config["active_tokens"][token] = now + AUTH_TOKEN_TTL
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def verify_auth_token(token):
|
||||||
|
"""Verify a session token is valid and not expired."""
|
||||||
|
if not token:
|
||||||
|
return False
|
||||||
|
with auth_lock:
|
||||||
|
expiry = auth_config["active_tokens"].get(token)
|
||||||
|
if expiry and expiry > time.time():
|
||||||
|
return True
|
||||||
|
# Remove expired token
|
||||||
|
auth_config["active_tokens"].pop(token, None)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def get_github_token():
|
def get_github_token():
|
||||||
"""Get GitHub token from environment or gh CLI."""
|
"""Get GitHub token from environment or gh CLI."""
|
||||||
@@ -187,12 +234,47 @@ def update_cache_async():
|
|||||||
metrics_cache["updating"] = False
|
metrics_cache["updating"] = False
|
||||||
|
|
||||||
|
|
||||||
|
def start_periodic_refresh(interval_hours):
|
||||||
|
"""Start a background thread that refreshes the cache periodically."""
|
||||||
|
interval_seconds = interval_hours * 3600
|
||||||
|
|
||||||
|
def refresh_loop():
|
||||||
|
while True:
|
||||||
|
time.sleep(interval_seconds)
|
||||||
|
print(f"Periodic refresh triggered (every {interval_hours}h)")
|
||||||
|
update_cache_async()
|
||||||
|
|
||||||
|
thread = threading.Thread(target=refresh_loop, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
print(f"Periodic refresh enabled: every {interval_hours} hours")
|
||||||
|
|
||||||
|
|
||||||
class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
||||||
"""HTTP request handler for the dashboard."""
|
"""HTTP request handler for the dashboard."""
|
||||||
|
|
||||||
def __init__(self, *args, directory=None, **kwargs):
|
def __init__(self, *args, directory=None, **kwargs):
|
||||||
super().__init__(*args, directory=directory, **kwargs)
|
super().__init__(*args, directory=directory, **kwargs)
|
||||||
|
|
||||||
|
def _send_json(self, data, status=200):
|
||||||
|
"""Send a JSON response."""
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Access-Control-Allow-Origin", "*")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(data).encode())
|
||||||
|
|
||||||
|
def _check_auth(self):
|
||||||
|
"""Check if request is authenticated. Returns True if OK, sends 401 and returns False otherwise."""
|
||||||
|
if not auth_config["enabled"]:
|
||||||
|
return True
|
||||||
|
auth_header = self.headers.get("Authorization", "")
|
||||||
|
if auth_header.startswith("Bearer "):
|
||||||
|
token = auth_header[7:]
|
||||||
|
if verify_auth_token(token):
|
||||||
|
return True
|
||||||
|
self._send_json({"error": "Unauthorized"}, status=401)
|
||||||
|
return False
|
||||||
|
|
||||||
def do_GET(self):
|
def do_GET(self):
|
||||||
parsed = urlparse(self.path)
|
parsed = urlparse(self.path)
|
||||||
|
|
||||||
@@ -201,13 +283,55 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
self.send_error(400, "Invalid path")
|
self.send_error(400, "Invalid path")
|
||||||
return
|
return
|
||||||
|
|
||||||
if parsed.path == "/api/metrics":
|
if parsed.path == "/api/auth-check":
|
||||||
self.handle_metrics_api(parsed)
|
self.handle_auth_check()
|
||||||
|
elif parsed.path == "/api/metrics":
|
||||||
|
if self._check_auth():
|
||||||
|
self.handle_metrics_api(parsed)
|
||||||
elif parsed.path == "/api/refresh":
|
elif parsed.path == "/api/refresh":
|
||||||
self.handle_refresh_api()
|
if self._check_auth():
|
||||||
|
self.handle_refresh_api()
|
||||||
else:
|
else:
|
||||||
super().do_GET()
|
super().do_GET()
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
|
||||||
|
if parsed.path == "/api/login":
|
||||||
|
self.handle_login()
|
||||||
|
else:
|
||||||
|
self.send_error(404, "Not Found")
|
||||||
|
|
||||||
|
def handle_auth_check(self):
|
||||||
|
"""Tell the frontend whether authentication is required."""
|
||||||
|
self._send_json({"auth_required": auth_config["enabled"]})
|
||||||
|
|
||||||
|
def handle_login(self):
|
||||||
|
"""Validate username/password and return a session token."""
|
||||||
|
content_length = int(self.headers.get("Content-Length", 0))
|
||||||
|
if content_length == 0 or content_length > 4096:
|
||||||
|
self._send_json({"error": "Invalid request"}, status=400)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
body = json.loads(self.rfile.read(content_length))
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
self._send_json({"error": "Invalid JSON"}, status=400)
|
||||||
|
return
|
||||||
|
|
||||||
|
username = body.get("username", "")
|
||||||
|
password = body.get("password", "")
|
||||||
|
|
||||||
|
if hmac.compare_digest(
|
||||||
|
username, auth_config["username"]
|
||||||
|
) and hmac.compare_digest(
|
||||||
|
hash_password(password), auth_config["password_hash"]
|
||||||
|
):
|
||||||
|
token = create_auth_token()
|
||||||
|
self._send_json({"token": token})
|
||||||
|
else:
|
||||||
|
self._send_json({"error": "Invalid username or password"}, status=401)
|
||||||
|
|
||||||
def handle_metrics_api(self, parsed):
|
def handle_metrics_api(self, parsed):
|
||||||
"""Handle /api/metrics endpoint."""
|
"""Handle /api/metrics endpoint."""
|
||||||
# Check cache with thread safety
|
# Check cache with thread safety
|
||||||
@@ -222,21 +346,12 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
|||||||
# Trigger background update
|
# Trigger background update
|
||||||
threading.Thread(target=update_cache_async, daemon=True).start()
|
threading.Thread(target=update_cache_async, daemon=True).start()
|
||||||
|
|
||||||
self.send_response(200)
|
self._send_json(data)
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps(data).encode())
|
|
||||||
|
|
||||||
def handle_refresh_api(self):
|
def handle_refresh_api(self):
|
||||||
"""Handle /api/refresh endpoint."""
|
"""Handle /api/refresh endpoint."""
|
||||||
threading.Thread(target=update_cache_async, daemon=True).start()
|
threading.Thread(target=update_cache_async, daemon=True).start()
|
||||||
|
self._send_json({"status": "refreshing"})
|
||||||
self.send_response(200)
|
|
||||||
self.send_header("Content-Type", "application/json")
|
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(json.dumps({"status": "refreshing"}).encode())
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
def log_message(self, format, *args):
|
||||||
"""Custom log format."""
|
"""Custom log format."""
|
||||||
@@ -254,8 +369,33 @@ def main():
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--fetch-on-start", action="store_true", help="Fetch metrics on startup"
|
"--fetch-on-start", action="store_true", help="Fetch metrics on startup"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--refresh-interval",
|
||||||
|
type=float,
|
||||||
|
default=12,
|
||||||
|
help="Auto-refresh interval in hours (default: 12, set to 0 to disable)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--username",
|
||||||
|
default=os.environ.get("DASHBOARD_USERNAME"),
|
||||||
|
help="Username for dashboard authentication (or set DASHBOARD_USERNAME env var)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--password",
|
||||||
|
default=os.environ.get("DASHBOARD_PASSWORD"),
|
||||||
|
help="Password for dashboard authentication (or set DASHBOARD_PASSWORD env var)",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Configure authentication if both username and password are provided
|
||||||
|
if args.username and args.password:
|
||||||
|
auth_config["enabled"] = True
|
||||||
|
auth_config["username"] = args.username
|
||||||
|
auth_config["password_hash"] = hash_password(args.password)
|
||||||
|
print(f"Authentication enabled for user: {args.username}")
|
||||||
|
elif args.username or args.password:
|
||||||
|
parser.error("Both --username and --password must be provided together")
|
||||||
|
|
||||||
# Change to dashboard directory
|
# Change to dashboard directory
|
||||||
dashboard_dir = Path(__file__).parent
|
dashboard_dir = Path(__file__).parent
|
||||||
os.chdir(dashboard_dir)
|
os.chdir(dashboard_dir)
|
||||||
@@ -264,6 +404,9 @@ def main():
|
|||||||
print("Fetching initial metrics data...")
|
print("Fetching initial metrics data...")
|
||||||
update_cache_async()
|
update_cache_async()
|
||||||
|
|
||||||
|
if args.refresh_interval > 0:
|
||||||
|
start_periodic_refresh(args.refresh_interval)
|
||||||
|
|
||||||
handler = lambda *a, **kw: DashboardHandler(*a, directory=str(dashboard_dir), **kw)
|
handler = lambda *a, **kw: DashboardHandler(*a, directory=str(dashboard_dir), **kw)
|
||||||
|
|
||||||
with socketserver.TCPServer((args.host, args.port), handler) as httpd:
|
with socketserver.TCPServer((args.host, args.port), handler) as httpd:
|
||||||
|
|||||||
Reference in New Issue
Block a user