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.defaults.color = '#8b949e';
|
||||
Chart.defaults.borderColor = '#30363d';
|
||||
Chart.defaults.color = '#94a3b8';
|
||||
Chart.defaults.borderColor = '#1e293b';
|
||||
|
||||
const chartColors = [
|
||||
'#58a6ff', '#3fb950', '#d29922', '#f85149', '#a371f7',
|
||||
'#79c0ff', '#56d364', '#e3b341', '#ff7b72', '#bc8cff'
|
||||
'#22d3ee', '#34d399', '#fbbf24', '#f87171', '#a78bfa',
|
||||
'#67e8f9', '#6ee7b7', '#fcd34d', '#fca5a5', '#c4b5fd'
|
||||
];
|
||||
|
||||
// Initialize the dashboard
|
||||
@@ -53,7 +53,7 @@ async function init() {
|
||||
async function loadData() {
|
||||
// Try local server API first (if running server.py)
|
||||
try {
|
||||
const response = await fetch('/api/metrics');
|
||||
const response = await fetch('/api/metrics', { headers: getAuthHeaders() });
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.length > 0 && data[0].results && data[0].results.length > 0) {
|
||||
@@ -726,12 +726,13 @@ function getChartOptions(yAxisLabel) {
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: '#21262d',
|
||||
borderColor: '#30363d',
|
||||
backgroundColor: '#1a2332',
|
||||
borderColor: 'rgba(148, 163, 184, 0.1)',
|
||||
borderWidth: 1,
|
||||
titleFont: { size: 13 },
|
||||
bodyFont: { size: 12 },
|
||||
padding: 12
|
||||
titleFont: { size: 13, family: "'DM Sans', sans-serif" },
|
||||
bodyFont: { size: 12, family: "'JetBrains Mono', monospace" },
|
||||
padding: 14,
|
||||
cornerRadius: 8
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
@@ -744,7 +745,7 @@ function getChartOptions(yAxisLabel) {
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: '#21262d'
|
||||
color: 'rgba(148, 163, 184, 0.06)'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
@@ -753,7 +754,7 @@ function getChartOptions(yAxisLabel) {
|
||||
text: yAxisLabel
|
||||
},
|
||||
grid: {
|
||||
color: '#21262d'
|
||||
color: 'rgba(148, 163, 184, 0.06)'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -832,5 +833,109 @@ function formatNumber(num) {
|
||||
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
|
||||
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 --host 0.0.0.0 # Allow external access
|
||||
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 hashlib
|
||||
import hmac
|
||||
import http.server
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socketserver
|
||||
import threading
|
||||
import time
|
||||
@@ -44,6 +50,47 @@ metrics_cache = {
|
||||
CACHE_TTL = 300 # 5 minutes
|
||||
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():
|
||||
"""Get GitHub token from environment or gh CLI."""
|
||||
@@ -187,12 +234,47 @@ def update_cache_async():
|
||||
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):
|
||||
"""HTTP request handler for the dashboard."""
|
||||
|
||||
def __init__(self, *args, directory=None, **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):
|
||||
parsed = urlparse(self.path)
|
||||
|
||||
@@ -201,13 +283,55 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
||||
self.send_error(400, "Invalid path")
|
||||
return
|
||||
|
||||
if parsed.path == "/api/metrics":
|
||||
self.handle_metrics_api(parsed)
|
||||
if parsed.path == "/api/auth-check":
|
||||
self.handle_auth_check()
|
||||
elif parsed.path == "/api/metrics":
|
||||
if self._check_auth():
|
||||
self.handle_metrics_api(parsed)
|
||||
elif parsed.path == "/api/refresh":
|
||||
self.handle_refresh_api()
|
||||
if self._check_auth():
|
||||
self.handle_refresh_api()
|
||||
else:
|
||||
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):
|
||||
"""Handle /api/metrics endpoint."""
|
||||
# Check cache with thread safety
|
||||
@@ -222,21 +346,12 @@ class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
||||
# Trigger background update
|
||||
threading.Thread(target=update_cache_async, daemon=True).start()
|
||||
|
||||
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(data).encode())
|
||||
self._send_json(data)
|
||||
|
||||
def handle_refresh_api(self):
|
||||
"""Handle /api/refresh endpoint."""
|
||||
threading.Thread(target=update_cache_async, daemon=True).start()
|
||||
|
||||
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())
|
||||
self._send_json({"status": "refreshing"})
|
||||
|
||||
def log_message(self, format, *args):
|
||||
"""Custom log format."""
|
||||
@@ -254,8 +369,33 @@ def main():
|
||||
parser.add_argument(
|
||||
"--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()
|
||||
|
||||
# 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
|
||||
dashboard_dir = Path(__file__).parent
|
||||
os.chdir(dashboard_dir)
|
||||
@@ -264,6 +404,9 @@ def main():
|
||||
print("Fetching initial metrics data...")
|
||||
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)
|
||||
|
||||
with socketserver.TCPServer((args.host, args.port), handler) as httpd:
|
||||
|
||||
Reference in New Issue
Block a user