Update performance dashboard for nightly tests (#18824)

This commit is contained in:
Kangyan-Zhou
2026-02-14 09:28:28 +08:00
committed by GitHub
parent 3299c4f9c1
commit 3a1c388b43
3 changed files with 993 additions and 259 deletions
+118 -13
View File
@@ -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
+155 -12
View File
@@ -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_auth_check()
elif parsed.path == "/api/metrics":
if self._check_auth():
self.handle_metrics_api(parsed) self.handle_metrics_api(parsed)
elif parsed.path == "/api/refresh": elif parsed.path == "/api/refresh":
if self._check_auth():
self.handle_refresh_api() 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: