mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
feat: add shared team dashboard, regression alerts, weekly digest edge functions
Dashboard: Supabase edge function serving self-contained HTML with PKCE OAuth, 6 parallel client-side REST queries, SVG charts, dark theme, auto-refresh, who's-online from heartbeats. Public URL. Regression alert: webhook on eval_runs INSERT, 5-min cooldown dedup via alert_cooldowns, Slack notification on >5% pass rate drop. Weekly digest: pg_cron Monday 9am UTC, aggregates 7-day team data, Slack message with evals/ships/sessions/costs. 15 tests. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Dashboard edge function — serves the team dashboard HTML.
|
||||
*
|
||||
* Public URL: https://<project>.supabase.co/functions/v1/dashboard
|
||||
* No auth required (the HTML page handles auth client-side via PKCE).
|
||||
*/
|
||||
|
||||
import { getDashboardHTML } from './ui.ts';
|
||||
|
||||
Deno.serve((_req: Request) => {
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? '';
|
||||
const anonKey = Deno.env.get('SUPABASE_ANON_KEY') ?? '';
|
||||
|
||||
const html = getDashboardHTML(supabaseUrl, anonKey);
|
||||
|
||||
return new Response(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,909 @@
|
||||
/**
|
||||
* Dashboard UI — self-contained HTML page for gstack's team engineering intelligence platform.
|
||||
*
|
||||
* Served by a Supabase edge function. All auth (PKCE), data fetching, and rendering
|
||||
* happen client-side. The server only injects supabaseUrl and anonKey into the template.
|
||||
*/
|
||||
|
||||
export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>gstack Dashboard</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ---------- Layout ---------- */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid #222;
|
||||
}
|
||||
.header h1 { font-size: 18px; font-weight: 600; }
|
||||
.header-right { display: flex; align-items: center; gap: 16px; font-size: 13px; color: #888; }
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
display: inline-block; margin-right: 6px;
|
||||
}
|
||||
.status-dot.ok { background: #4ade80; }
|
||||
.status-dot.err { background: #ef4444; }
|
||||
.btn-signout {
|
||||
background: transparent; border: 1px solid #333; color: #888;
|
||||
padding: 4px 12px; border-radius: 6px; cursor: pointer; font-size: 13px;
|
||||
}
|
||||
.btn-signout:hover { color: #e5e5e5; border-color: #555; }
|
||||
|
||||
/* ---------- Tabs ---------- */
|
||||
.tabs {
|
||||
display: flex; gap: 0; border-bottom: 1px solid #222;
|
||||
padding: 0 24px; overflow-x: auto;
|
||||
}
|
||||
.tab {
|
||||
padding: 10px 18px; cursor: pointer; color: #888;
|
||||
border-bottom: 2px solid transparent; white-space: nowrap;
|
||||
font-size: 13px; font-weight: 500; transition: color 0.15s;
|
||||
}
|
||||
.tab:hover { color: #ccc; }
|
||||
.tab.active { color: #4ade80; border-bottom-color: #4ade80; }
|
||||
|
||||
/* ---------- Content ---------- */
|
||||
.content { padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* ---------- Cards ---------- */
|
||||
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||||
.stat-card {
|
||||
background: #141414; border: 1px solid #222; border-radius: 10px; padding: 20px;
|
||||
}
|
||||
.stat-card .label { font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||||
.stat-card .value { font-size: 28px; font-weight: 700; font-family: 'SF Mono', 'Fira Code', monospace; }
|
||||
|
||||
/* ---------- Tables ---------- */
|
||||
.panel { background: #141414; border: 1px solid #222; border-radius: 10px; padding: 20px; margin-bottom: 24px; }
|
||||
.panel h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: #666; padding: 8px 12px; border-bottom: 1px solid #222; }
|
||||
td { padding: 8px 12px; border-bottom: 1px solid #1a1a1a; font-size: 13px; }
|
||||
td.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
||||
a { color: #4ade80; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ---------- Health score colors ---------- */
|
||||
.score-green { color: #4ade80; }
|
||||
.score-yellow { color: #facc15; }
|
||||
.score-red { color: #ef4444; }
|
||||
|
||||
/* ---------- Online dot ---------- */
|
||||
.online-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%; background: #4ade80;
|
||||
display: inline-block; margin-right: 6px;
|
||||
}
|
||||
|
||||
/* ---------- Login ---------- */
|
||||
.login-card {
|
||||
position: fixed; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
.login-card .inner {
|
||||
background: #141414; border: 1px solid #222; border-radius: 12px;
|
||||
padding: 40px; text-align: center; max-width: 380px;
|
||||
}
|
||||
.login-card h2 { font-size: 20px; margin-bottom: 8px; }
|
||||
.login-card p { color: #888; margin-bottom: 24px; font-size: 14px; }
|
||||
.btn-github {
|
||||
background: #e5e5e5; color: #0a0a0a; border: none; padding: 10px 24px;
|
||||
border-radius: 8px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.btn-github:hover { background: #fff; }
|
||||
|
||||
/* ---------- Error / empty ---------- */
|
||||
.panel-error { color: #888; font-size: 13px; padding: 16px 0; }
|
||||
.panel-empty { color: #555; font-size: 13px; padding: 16px 0; font-style: italic; }
|
||||
|
||||
/* ---------- SVG charts ---------- */
|
||||
.chart-container { margin-bottom: 16px; }
|
||||
svg text { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; fill: #888; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Login screen -->
|
||||
<div id="login-screen" class="login-card" style="display:none">
|
||||
<div class="inner">
|
||||
<h2>gstack Dashboard</h2>
|
||||
<p>Team engineering intelligence</p>
|
||||
<button class="btn-github" id="btn-login">Sign in with GitHub</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard (shown after auth) -->
|
||||
<div id="dashboard" style="display:none">
|
||||
<div class="header">
|
||||
<h1>gstack Dashboard</h1>
|
||||
<div class="header-right">
|
||||
<span><span id="status-dot" class="status-dot ok"></span><span id="status-text">Updated just now</span></span>
|
||||
<button class="btn-signout" id="btn-signout">Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="overview">Overview</div>
|
||||
<div class="tab" data-tab="evals">Evals</div>
|
||||
<div class="tab" data-tab="ships">Ships</div>
|
||||
<div class="tab" data-tab="costs">Costs</div>
|
||||
<div class="tab" data-tab="leaderboard">Leaderboard</div>
|
||||
<div class="tab" data-tab="qa">QA</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- Overview -->
|
||||
<div class="tab-panel active" id="panel-overview">
|
||||
<div class="stat-row">
|
||||
<div class="stat-card"><div class="label">Eval Runs This Week</div><div class="value" id="stat-evals-week">-</div></div>
|
||||
<div class="stat-card"><div class="label">Ships This Week</div><div class="value" id="stat-ships-week">-</div></div>
|
||||
<div class="stat-card"><div class="label">Active Now</div><div class="value" id="stat-active-now">-</div></div>
|
||||
<div class="stat-card"><div class="label">Cost This Week</div><div class="value" id="stat-cost-week">-</div></div>
|
||||
</div>
|
||||
<div class="panel"><h3>Recent Eval Runs</h3><table id="tbl-overview-evals"><thead><tr><th>Date</th><th>Branch</th><th>Pass Rate</th><th>Cost</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-overview-evals" style="display:none">No data yet</div></div>
|
||||
<div class="panel"><h3>Recent Ships</h3><table id="tbl-overview-ships"><thead><tr><th>Date</th><th>Version</th><th>Branch</th><th>PR</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-overview-ships" style="display:none">No data yet</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Evals -->
|
||||
<div class="tab-panel" id="panel-evals">
|
||||
<div class="panel"><h3>Pass Rate Trend</h3><div class="chart-container" id="chart-sparkline"></div></div>
|
||||
<div class="panel"><h3>Recent Eval Runs</h3><table id="tbl-evals"><thead><tr><th>Date</th><th>User</th><th>Branch</th><th>Pass Rate</th><th>Cost</th><th>Tier</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-evals" style="display:none">No data yet</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Ships -->
|
||||
<div class="tab-panel" id="panel-ships">
|
||||
<div class="panel"><h3>Recent Ships</h3><table id="tbl-ships"><thead><tr><th>Date</th><th>Version</th><th>Branch</th><th>PR</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-ships" style="display:none">No data yet</div></div>
|
||||
<div class="panel"><h3>Ships Per Person This Week</h3><div class="chart-container" id="chart-ships-per-person"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Costs -->
|
||||
<div class="tab-panel" id="panel-costs">
|
||||
<div class="stat-row">
|
||||
<div class="stat-card"><div class="label">Total All-Time Cost</div><div class="value" id="stat-cost-total">-</div></div>
|
||||
</div>
|
||||
<div class="panel"><h3>Weekly Cost Trend</h3><div class="chart-container" id="chart-weekly-cost"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Leaderboard -->
|
||||
<div class="tab-panel" id="panel-leaderboard">
|
||||
<div class="panel"><h3>This Week</h3><table id="tbl-leaderboard"><thead><tr><th>#</th><th>Who</th><th>Ships</th><th>Evals</th><th>Sessions</th><th>Pass Rate</th><th>Cost</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-leaderboard" style="display:none">No data yet</div></div>
|
||||
</div>
|
||||
|
||||
<!-- QA -->
|
||||
<div class="tab-panel" id="panel-qa">
|
||||
<div class="panel"><h3>Recent QA Reports</h3><table id="tbl-qa"><thead><tr><th>Date</th><th>Repo</th><th>Health Score</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-qa" style="display:none">No data yet</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// ---- Config injected by the edge function ----
|
||||
const SUPABASE_URL = ${JSON.stringify(supabaseUrl)};
|
||||
const ANON_KEY = ${JSON.stringify(anonKey)};
|
||||
|
||||
// ---- DOM refs ----
|
||||
const $login = document.getElementById('login-screen');
|
||||
const $dashboard = document.getElementById('dashboard');
|
||||
const $statusDot = document.getElementById('status-dot');
|
||||
const $statusText = document.getElementById('status-text');
|
||||
|
||||
// ---- State ----
|
||||
let accessToken = null;
|
||||
let refreshToken = null;
|
||||
let lastFetchTime = 0;
|
||||
let refreshTimer = null;
|
||||
|
||||
// All fetched data
|
||||
let data = { evalRuns: [], shipLogs: [], evalCosts: [], qaReports: [], transcripts: [], heartbeats: [] };
|
||||
|
||||
// ================================================================
|
||||
// PKCE Auth helpers
|
||||
// ================================================================
|
||||
|
||||
/** Generate a random string for PKCE code_verifier (43 chars, URL-safe) */
|
||||
function generateVerifier() {
|
||||
const arr = new Uint8Array(32);
|
||||
crypto.getRandomValues(arr);
|
||||
return btoa(String.fromCharCode(...arr))
|
||||
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
|
||||
.slice(0, 43);
|
||||
}
|
||||
|
||||
/** SHA-256 hash, then base64url encode for code_challenge */
|
||||
async function sha256Challenge(verifier) {
|
||||
const encoded = new TextEncoder().encode(verifier);
|
||||
const hash = await crypto.subtle.digest('SHA-256', encoded);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(hash)))
|
||||
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Decode a JWT payload (base64url middle segment) */
|
||||
function decodeJWT(token) {
|
||||
try {
|
||||
const payload = token.split('.')[1];
|
||||
const padded = payload.replace(/-/g, '+').replace(/_/g, '/');
|
||||
return JSON.parse(atob(padded));
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** Check if a JWT is expired (with 60s buffer) */
|
||||
function isExpired(token) {
|
||||
const claims = decodeJWT(token);
|
||||
if (!claims || !claims.exp) return true;
|
||||
return claims.exp * 1000 < Date.now() - 60000;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Auth flow
|
||||
// ================================================================
|
||||
|
||||
/** Try to restore session from localStorage */
|
||||
function restoreSession() {
|
||||
accessToken = localStorage.getItem('sb-access-token');
|
||||
refreshToken = localStorage.getItem('sb-refresh-token');
|
||||
if (accessToken && !isExpired(accessToken)) return true;
|
||||
if (accessToken && refreshToken) return tryRefresh();
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Exchange refresh token for a new access token */
|
||||
async function tryRefresh() {
|
||||
try {
|
||||
const res = await fetch(SUPABASE_URL + '/auth/v1/token?grant_type=refresh_token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'apikey': ANON_KEY },
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
if (!res.ok) throw new Error('refresh failed');
|
||||
const json = await res.json();
|
||||
accessToken = json.access_token;
|
||||
refreshToken = json.refresh_token;
|
||||
localStorage.setItem('sb-access-token', accessToken);
|
||||
localStorage.setItem('sb-refresh-token', refreshToken);
|
||||
return true;
|
||||
} catch {
|
||||
signOut();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Exchange authorization code for tokens (PKCE) */
|
||||
async function exchangeCode(code) {
|
||||
const verifier = sessionStorage.getItem('pkce-verifier');
|
||||
if (!verifier) { showLogin(); return; }
|
||||
try {
|
||||
const res = await fetch(SUPABASE_URL + '/auth/v1/token?grant_type=pkce', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'apikey': ANON_KEY },
|
||||
body: JSON.stringify({ auth_code: code, code_verifier: verifier })
|
||||
});
|
||||
if (!res.ok) throw new Error('token exchange failed');
|
||||
const json = await res.json();
|
||||
accessToken = json.access_token;
|
||||
refreshToken = json.refresh_token;
|
||||
localStorage.setItem('sb-access-token', accessToken);
|
||||
localStorage.setItem('sb-refresh-token', refreshToken);
|
||||
sessionStorage.removeItem('pkce-verifier');
|
||||
// Clean URL
|
||||
window.history.replaceState({}, '', window.location.pathname);
|
||||
showDashboard();
|
||||
} catch {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
/** Redirect to GitHub OAuth with PKCE */
|
||||
async function startLogin() {
|
||||
const verifier = generateVerifier();
|
||||
sessionStorage.setItem('pkce-verifier', verifier);
|
||||
const challenge = await sha256Challenge(verifier);
|
||||
const redirectTo = window.location.origin + window.location.pathname;
|
||||
const url = SUPABASE_URL + '/auth/v1/authorize'
|
||||
+ '?provider=github'
|
||||
+ '&redirect_to=' + encodeURIComponent(redirectTo)
|
||||
+ '&flow_type=pkce'
|
||||
+ '&code_challenge_method=s256'
|
||||
+ '&code_challenge=' + challenge;
|
||||
window.location.href = url;
|
||||
}
|
||||
|
||||
function signOut() {
|
||||
localStorage.removeItem('sb-access-token');
|
||||
localStorage.removeItem('sb-refresh-token');
|
||||
accessToken = null;
|
||||
refreshToken = null;
|
||||
showLogin();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
$login.style.display = 'flex';
|
||||
$dashboard.style.display = 'none';
|
||||
stopAutoRefresh();
|
||||
}
|
||||
|
||||
function showDashboard() {
|
||||
$login.style.display = 'none';
|
||||
$dashboard.style.display = 'block';
|
||||
fetchAll();
|
||||
startAutoRefresh();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Data fetching
|
||||
// ================================================================
|
||||
|
||||
/** Fetch a single REST endpoint with auth headers */
|
||||
async function apiFetch(path) {
|
||||
const res = await fetch(SUPABASE_URL + '/rest/v1/' + path, {
|
||||
headers: {
|
||||
'apikey': ANON_KEY,
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
}
|
||||
});
|
||||
if (res.status === 401) {
|
||||
const ok = await tryRefresh();
|
||||
if (!ok) { signOut(); throw new Error('auth expired'); }
|
||||
// Retry once with new token
|
||||
const retry = await fetch(SUPABASE_URL + '/rest/v1/' + path, {
|
||||
headers: {
|
||||
'apikey': ANON_KEY,
|
||||
'Authorization': 'Bearer ' + accessToken
|
||||
}
|
||||
});
|
||||
if (!retry.ok) throw new Error('fetch failed: ' + path);
|
||||
return retry.json();
|
||||
}
|
||||
if (!res.ok) throw new Error('fetch failed: ' + path);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** Fetch all 6 data sources in parallel */
|
||||
async function fetchAll() {
|
||||
const fetches = [
|
||||
apiFetch('eval_runs?order=timestamp.desc&limit=100').then(d => { data.evalRuns = d; }).catch(() => { data.evalRuns = null; }),
|
||||
apiFetch('ship_logs?order=created_at.desc&limit=100').then(d => { data.shipLogs = d; }).catch(() => { data.shipLogs = null; }),
|
||||
apiFetch('eval_costs?order=created_at.desc&limit=200').then(d => { data.evalCosts = d; }).catch(() => { data.evalCosts = null; }),
|
||||
apiFetch('qa_reports?order=created_at.desc&limit=100').then(d => { data.qaReports = d; }).catch(() => { data.qaReports = null; }),
|
||||
apiFetch('session_transcripts?order=started_at.desc&limit=100').then(d => { data.transcripts = d; }).catch(() => { data.transcripts = null; }),
|
||||
apiFetch('sync_heartbeats?order=timestamp.desc&limit=50').then(d => { data.heartbeats = d; }).catch(() => { data.heartbeats = null; }),
|
||||
];
|
||||
await Promise.allSettled(fetches);
|
||||
lastFetchTime = Date.now();
|
||||
updateStatus(true);
|
||||
renderAll();
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Auto-refresh (30s, pauses when tab hidden)
|
||||
// ================================================================
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh();
|
||||
refreshTimer = setInterval(() => { fetchAll(); }, 30000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null; }
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.hidden) {
|
||||
stopAutoRefresh();
|
||||
} else {
|
||||
// Fetch immediately when tab becomes visible, then resume timer
|
||||
if (accessToken) { fetchAll(); startAutoRefresh(); }
|
||||
}
|
||||
});
|
||||
|
||||
function updateStatus(ok) {
|
||||
$statusDot.className = 'status-dot ' + (ok ? 'ok' : 'err');
|
||||
updateStatusText();
|
||||
}
|
||||
|
||||
function updateStatusText() {
|
||||
const elapsed = Math.floor((Date.now() - lastFetchTime) / 1000);
|
||||
if (elapsed < 5) { $statusText.textContent = 'Updated just now'; }
|
||||
else { $statusText.textContent = 'Updated ' + elapsed + 's ago'; }
|
||||
}
|
||||
// Update the "Xs ago" label every 5 seconds
|
||||
setInterval(updateStatusText, 5000);
|
||||
|
||||
// ================================================================
|
||||
// Helpers
|
||||
// ================================================================
|
||||
|
||||
/** Format an ISO date string to a short readable form */
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '-';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** Format USD cost */
|
||||
function fmtCost(n) {
|
||||
if (n == null || isNaN(n)) return '-';
|
||||
return '$' + Number(n).toFixed(2);
|
||||
}
|
||||
|
||||
/** Get the start of the current ISO week (Monday) */
|
||||
function weekStart() {
|
||||
const now = new Date();
|
||||
const day = now.getDay();
|
||||
const diff = now.getDate() - day + (day === 0 ? -6 : 1);
|
||||
const monday = new Date(now);
|
||||
monday.setHours(0, 0, 0, 0);
|
||||
monday.setDate(diff);
|
||||
return monday;
|
||||
}
|
||||
|
||||
/** Check if a timestamp is within the last N minutes */
|
||||
function withinMinutes(iso, mins) {
|
||||
if (!iso) return false;
|
||||
return (Date.now() - new Date(iso).getTime()) < mins * 60 * 1000;
|
||||
}
|
||||
|
||||
/** Create a table row, setting textContent for each cell (XSS safe) */
|
||||
function makeRow(cells) {
|
||||
const tr = document.createElement('tr');
|
||||
cells.forEach(function(cell) {
|
||||
const td = document.createElement('td');
|
||||
if (typeof cell === 'object' && cell.href) {
|
||||
// Clickable link — still safe, we control the href
|
||||
const a = document.createElement('a');
|
||||
a.href = cell.href;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noopener';
|
||||
a.textContent = cell.text || cell.href;
|
||||
td.appendChild(a);
|
||||
} else if (typeof cell === 'object' && cell.html) {
|
||||
// For inline dots — only used with controlled content
|
||||
td.innerHTML = cell.html;
|
||||
} else {
|
||||
td.textContent = String(cell != null ? cell : '-');
|
||||
if (typeof cell === 'number' || (typeof cell === 'string' && /^[\\$\\d\\.\\-]+$/.test(cell))) {
|
||||
td.classList.add('mono');
|
||||
}
|
||||
}
|
||||
tr.appendChild(td);
|
||||
});
|
||||
return tr;
|
||||
}
|
||||
|
||||
/** Show/hide empty placeholder */
|
||||
function toggleEmpty(tableId, emptyId, hasData) {
|
||||
const tbl = document.getElementById(tableId);
|
||||
const empty = document.getElementById(emptyId);
|
||||
if (!tbl || !empty) return;
|
||||
tbl.style.display = hasData ? '' : 'none';
|
||||
empty.style.display = hasData ? 'none' : '';
|
||||
}
|
||||
|
||||
/** Show error text inside a panel */
|
||||
function showPanelError(tableId, emptyId) {
|
||||
const empty = document.getElementById(emptyId);
|
||||
if (empty) {
|
||||
empty.textContent = 'Could not load data';
|
||||
empty.className = 'panel-error';
|
||||
empty.style.display = '';
|
||||
}
|
||||
const tbl = document.getElementById(tableId);
|
||||
if (tbl) tbl.style.display = 'none';
|
||||
}
|
||||
|
||||
/** Clear a table body */
|
||||
function clearTbody(tableId) {
|
||||
const tbl = document.getElementById(tableId);
|
||||
if (!tbl) return null;
|
||||
const tbody = tbl.querySelector('tbody');
|
||||
if (tbody) tbody.innerHTML = '';
|
||||
return tbody;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// SVG chart builders
|
||||
// ================================================================
|
||||
|
||||
/** Sparkline: dots connected by a polyline */
|
||||
function renderSparkline(containerId, values) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
if (!values || values.length === 0) { el.innerHTML = '<span class="panel-empty">No data yet</span>'; return; }
|
||||
|
||||
const W = 600, H = 120, PAD = 30;
|
||||
const max = Math.max(...values, 1);
|
||||
const min = Math.min(...values, 0);
|
||||
const range = max - min || 1;
|
||||
const pts = values.map(function(v, i) {
|
||||
const x = PAD + (i / Math.max(values.length - 1, 1)) * (W - PAD * 2);
|
||||
const y = H - PAD - ((v - min) / range) * (H - PAD * 2);
|
||||
return { x: x, y: y, v: v };
|
||||
});
|
||||
|
||||
const polyline = pts.map(function(p) { return p.x + ',' + p.y; }).join(' ');
|
||||
const dots = pts.map(function(p) {
|
||||
return '<circle cx="' + p.x + '" cy="' + p.y + '" r="4" fill="#4ade80" />';
|
||||
}).join('');
|
||||
|
||||
// Y-axis labels
|
||||
const yLabels = '<text x="4" y="' + (PAD + 4) + '">' + Math.round(max * 100) + '%</text>'
|
||||
+ '<text x="4" y="' + (H - PAD + 4) + '">' + Math.round(min * 100) + '%</text>';
|
||||
|
||||
el.innerHTML = '<svg viewBox="0 0 ' + W + ' ' + H + '" width="100%" height="' + H + '">'
|
||||
+ yLabels
|
||||
+ '<polyline points="' + polyline + '" fill="none" stroke="#4ade80" stroke-width="2" />'
|
||||
+ dots
|
||||
+ '</svg>';
|
||||
}
|
||||
|
||||
/** Horizontal bar chart */
|
||||
function renderHBarChart(containerId, items) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
if (!items || items.length === 0) { el.innerHTML = '<span class="panel-empty">No data yet</span>'; return; }
|
||||
|
||||
const W = 600, barH = 28, gap = 6;
|
||||
const H = items.length * (barH + gap) + 20;
|
||||
const maxVal = Math.max(...items.map(function(d) { return d.value; }), 1);
|
||||
const LABEL_W = 120, BAR_AREA = W - LABEL_W - 60;
|
||||
|
||||
let bars = '';
|
||||
items.forEach(function(item, i) {
|
||||
const y = i * (barH + gap) + 10;
|
||||
const bw = Math.max((item.value / maxVal) * BAR_AREA, 2);
|
||||
bars += '<text x="' + (LABEL_W - 8) + '" y="' + (y + barH / 2 + 4) + '" text-anchor="end" fill="#ccc" font-size="12">'
|
||||
+ escapeHTML(item.label) + '</text>';
|
||||
bars += '<rect x="' + LABEL_W + '" y="' + y + '" width="' + bw + '" height="' + barH + '" rx="4" fill="#4ade80" opacity="0.8" />';
|
||||
bars += '<text x="' + (LABEL_W + bw + 8) + '" y="' + (y + barH / 2 + 4) + '" fill="#888" font-size="12">' + item.value + '</text>';
|
||||
});
|
||||
|
||||
el.innerHTML = '<svg viewBox="0 0 ' + W + ' ' + H + '" width="100%" height="' + H + '">' + bars + '</svg>';
|
||||
}
|
||||
|
||||
/** Vertical bar chart (for weekly cost) */
|
||||
function renderVBarChart(containerId, items) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
if (!items || items.length === 0) { el.innerHTML = '<span class="panel-empty">No data yet</span>'; return; }
|
||||
|
||||
const W = 600, H = 180, PAD_BOTTOM = 40, PAD_TOP = 20, PAD_LEFT = 50;
|
||||
const maxVal = Math.max(...items.map(function(d) { return d.value; }), 0.01);
|
||||
const barW = Math.min(50, (W - PAD_LEFT - 20) / items.length - 8);
|
||||
const chartH = H - PAD_BOTTOM - PAD_TOP;
|
||||
|
||||
let bars = '';
|
||||
items.forEach(function(item, i) {
|
||||
const x = PAD_LEFT + i * ((W - PAD_LEFT - 20) / items.length) + 4;
|
||||
const bh = Math.max((item.value / maxVal) * chartH, 2);
|
||||
const y = PAD_TOP + chartH - bh;
|
||||
bars += '<rect x="' + x + '" y="' + y + '" width="' + barW + '" height="' + bh + '" rx="3" fill="#4ade80" opacity="0.8" />';
|
||||
// Value on top
|
||||
bars += '<text x="' + (x + barW / 2) + '" y="' + (y - 6) + '" text-anchor="middle" font-size="10" fill="#888">$' + item.value.toFixed(2) + '</text>';
|
||||
// Label at bottom
|
||||
bars += '<text x="' + (x + barW / 2) + '" y="' + (H - 10) + '" text-anchor="middle" font-size="10" fill="#666">' + escapeHTML(item.label) + '</text>';
|
||||
});
|
||||
|
||||
// Y-axis line
|
||||
bars += '<line x1="' + PAD_LEFT + '" y1="' + PAD_TOP + '" x2="' + PAD_LEFT + '" y2="' + (H - PAD_BOTTOM) + '" stroke="#333" />';
|
||||
|
||||
el.innerHTML = '<svg viewBox="0 0 ' + W + ' ' + H + '" width="100%" height="' + H + '">' + bars + '</svg>';
|
||||
}
|
||||
|
||||
/** Minimal HTML escape for chart labels (numbers + short strings we control) */
|
||||
function escapeHTML(str) {
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Render functions
|
||||
// ================================================================
|
||||
|
||||
function renderAll() {
|
||||
renderOverview();
|
||||
renderEvals();
|
||||
renderShips();
|
||||
renderCosts();
|
||||
renderLeaderboard();
|
||||
renderQA();
|
||||
}
|
||||
|
||||
// ---- Overview ----
|
||||
function renderOverview() {
|
||||
const ws = weekStart();
|
||||
|
||||
// Stat cards
|
||||
if (data.evalRuns) {
|
||||
const thisWeek = data.evalRuns.filter(function(r) { return new Date(r.timestamp) >= ws; });
|
||||
document.getElementById('stat-evals-week').textContent = thisWeek.length;
|
||||
}
|
||||
if (data.shipLogs) {
|
||||
const thisWeek = data.shipLogs.filter(function(r) { return new Date(r.created_at) >= ws; });
|
||||
document.getElementById('stat-ships-week').textContent = thisWeek.length;
|
||||
}
|
||||
if (data.heartbeats) {
|
||||
const active = data.heartbeats.filter(function(h) { return withinMinutes(h.timestamp, 15); });
|
||||
// Count unique hostnames
|
||||
const uniqueHosts = new Set(active.map(function(h) { return h.hostname || h.user_id; }));
|
||||
document.getElementById('stat-active-now').textContent = uniqueHosts.size;
|
||||
}
|
||||
if (data.evalCosts) {
|
||||
const thisWeek = data.evalCosts.filter(function(c) { return new Date(c.created_at) >= ws; });
|
||||
const total = thisWeek.reduce(function(s, c) { return s + Number(c.estimated_cost_usd || 0); }, 0);
|
||||
document.getElementById('stat-cost-week').textContent = fmtCost(total);
|
||||
}
|
||||
|
||||
// Recent eval runs table (last 10)
|
||||
if (data.evalRuns === null) {
|
||||
showPanelError('tbl-overview-evals', 'empty-overview-evals');
|
||||
} else {
|
||||
const tbody = clearTbody('tbl-overview-evals');
|
||||
const rows = data.evalRuns.slice(0, 10);
|
||||
rows.forEach(function(r) {
|
||||
const rate = r.total_tests ? ((r.passed / r.total_tests) * 100).toFixed(0) + '%' : '-';
|
||||
tbody.appendChild(makeRow([fmtDate(r.timestamp), r.branch || '-', rate, fmtCost(r.total_cost_usd)]));
|
||||
});
|
||||
toggleEmpty('tbl-overview-evals', 'empty-overview-evals', rows.length > 0);
|
||||
}
|
||||
|
||||
// Recent ships table (last 10)
|
||||
if (data.shipLogs === null) {
|
||||
showPanelError('tbl-overview-ships', 'empty-overview-ships');
|
||||
} else {
|
||||
const tbody = clearTbody('tbl-overview-ships');
|
||||
const rows = data.shipLogs.slice(0, 10);
|
||||
rows.forEach(function(r) {
|
||||
const pr = r.pr_url ? { href: r.pr_url, text: 'PR' } : '-';
|
||||
tbody.appendChild(makeRow([fmtDate(r.created_at), r.version || '-', r.branch || '-', pr]));
|
||||
});
|
||||
toggleEmpty('tbl-overview-ships', 'empty-overview-ships', rows.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Evals ----
|
||||
function renderEvals() {
|
||||
if (data.evalRuns === null) {
|
||||
showPanelError('tbl-evals', 'empty-evals');
|
||||
return;
|
||||
}
|
||||
|
||||
// Sparkline — pass rate for last 20 runs (oldest first for left-to-right)
|
||||
const last20 = data.evalRuns.slice(0, 20).reverse();
|
||||
const rates = last20.map(function(r) {
|
||||
return r.total_tests ? r.passed / r.total_tests : 0;
|
||||
});
|
||||
renderSparkline('chart-sparkline', rates);
|
||||
|
||||
// Table
|
||||
const tbody = clearTbody('tbl-evals');
|
||||
const rows = data.evalRuns.slice(0, 20);
|
||||
rows.forEach(function(r) {
|
||||
const rate = r.total_tests ? ((r.passed / r.total_tests) * 100).toFixed(0) + '%' : '-';
|
||||
tbody.appendChild(makeRow([
|
||||
fmtDate(r.timestamp),
|
||||
r.hostname || '-',
|
||||
r.branch || '-',
|
||||
rate,
|
||||
fmtCost(r.total_cost_usd),
|
||||
r.tier || '-'
|
||||
]));
|
||||
});
|
||||
toggleEmpty('tbl-evals', 'empty-evals', rows.length > 0);
|
||||
}
|
||||
|
||||
// ---- Ships ----
|
||||
function renderShips() {
|
||||
if (data.shipLogs === null) {
|
||||
showPanelError('tbl-ships', 'empty-ships');
|
||||
return;
|
||||
}
|
||||
|
||||
// Table
|
||||
const tbody = clearTbody('tbl-ships');
|
||||
const rows = data.shipLogs.slice(0, 20);
|
||||
rows.forEach(function(r) {
|
||||
const pr = r.pr_url ? { href: r.pr_url, text: 'PR' } : '-';
|
||||
tbody.appendChild(makeRow([fmtDate(r.created_at), r.version || '-', r.branch || '-', pr]));
|
||||
});
|
||||
toggleEmpty('tbl-ships', 'empty-ships', rows.length > 0);
|
||||
|
||||
// Ships per person this week (horizontal bar chart)
|
||||
const ws = weekStart();
|
||||
const thisWeek = data.shipLogs.filter(function(r) { return new Date(r.created_at) >= ws; });
|
||||
const counts = {};
|
||||
thisWeek.forEach(function(r) {
|
||||
const who = r.user_id || 'unknown';
|
||||
counts[who] = (counts[who] || 0) + 1;
|
||||
});
|
||||
const items = Object.keys(counts).map(function(k) {
|
||||
return { label: k.slice(0, 8), value: counts[k] };
|
||||
}).sort(function(a, b) { return b.value - a.value; });
|
||||
renderHBarChart('chart-ships-per-person', items);
|
||||
}
|
||||
|
||||
// ---- Costs ----
|
||||
function renderCosts() {
|
||||
if (data.evalCosts === null) return;
|
||||
|
||||
// Total all-time
|
||||
const allTime = data.evalCosts.reduce(function(s, c) { return s + Number(c.estimated_cost_usd || 0); }, 0);
|
||||
document.getElementById('stat-cost-total').textContent = fmtCost(allTime);
|
||||
|
||||
// Weekly cost trend — last 8 weeks
|
||||
const now = new Date();
|
||||
const weeks = [];
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - (i * 7 + now.getDay()));
|
||||
start.setHours(0, 0, 0, 0);
|
||||
const end = new Date(start);
|
||||
end.setDate(end.getDate() + 7);
|
||||
const label = (start.getMonth() + 1) + '/' + start.getDate();
|
||||
const weekCosts = data.evalCosts.filter(function(c) {
|
||||
const d = new Date(c.created_at);
|
||||
return d >= start && d < end;
|
||||
});
|
||||
const total = weekCosts.reduce(function(s, c) { return s + Number(c.estimated_cost_usd || 0); }, 0);
|
||||
weeks.push({ label: label, value: total });
|
||||
}
|
||||
renderVBarChart('chart-weekly-cost', weeks);
|
||||
}
|
||||
|
||||
// ---- Leaderboard ----
|
||||
function renderLeaderboard() {
|
||||
const ws = weekStart();
|
||||
const hasAnyData = data.evalRuns || data.shipLogs || data.transcripts;
|
||||
if (!hasAnyData) {
|
||||
showPanelError('tbl-leaderboard', 'empty-leaderboard');
|
||||
return;
|
||||
}
|
||||
|
||||
// Aggregate per user_id
|
||||
const board = {};
|
||||
function ensure(uid) {
|
||||
if (!board[uid]) board[uid] = { ships: 0, evals: 0, sessions: 0, passed: 0, total_tests: 0, cost: 0, online: false };
|
||||
}
|
||||
|
||||
if (data.shipLogs) {
|
||||
data.shipLogs.filter(function(r) { return new Date(r.created_at) >= ws; }).forEach(function(r) {
|
||||
const uid = r.user_id || 'unknown';
|
||||
ensure(uid);
|
||||
board[uid].ships++;
|
||||
});
|
||||
}
|
||||
if (data.evalRuns) {
|
||||
data.evalRuns.filter(function(r) { return new Date(r.timestamp) >= ws; }).forEach(function(r) {
|
||||
const uid = r.user_id || r.hostname || 'unknown';
|
||||
ensure(uid);
|
||||
board[uid].evals++;
|
||||
board[uid].passed += r.passed || 0;
|
||||
board[uid].total_tests += r.total_tests || 0;
|
||||
board[uid].cost += Number(r.total_cost_usd || 0);
|
||||
});
|
||||
}
|
||||
if (data.transcripts) {
|
||||
data.transcripts.filter(function(r) { return new Date(r.started_at) >= ws; }).forEach(function(r) {
|
||||
const uid = r.user_id || 'unknown';
|
||||
ensure(uid);
|
||||
board[uid].sessions++;
|
||||
});
|
||||
}
|
||||
// Online status from heartbeats
|
||||
if (data.heartbeats) {
|
||||
data.heartbeats.forEach(function(h) {
|
||||
const uid = h.user_id || h.hostname;
|
||||
if (uid && board[uid] && withinMinutes(h.timestamp, 15)) {
|
||||
board[uid].online = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by ships desc, then evals desc
|
||||
const sorted = Object.keys(board).map(function(uid) {
|
||||
return Object.assign({ uid: uid }, board[uid]);
|
||||
}).sort(function(a, b) { return (b.ships + b.evals) - (a.ships + a.evals); });
|
||||
|
||||
const tbody = clearTbody('tbl-leaderboard');
|
||||
sorted.forEach(function(entry, i) {
|
||||
const rate = entry.total_tests ? ((entry.passed / entry.total_tests) * 100).toFixed(0) + '%' : '-';
|
||||
const nameCell = entry.online
|
||||
? { html: '<span class="online-dot"></span>' + escapeHTML(entry.uid.slice(0, 8)) }
|
||||
: entry.uid.slice(0, 8);
|
||||
tbody.appendChild(makeRow([
|
||||
i + 1,
|
||||
nameCell,
|
||||
entry.ships,
|
||||
entry.evals,
|
||||
entry.sessions,
|
||||
rate,
|
||||
fmtCost(entry.cost)
|
||||
]));
|
||||
});
|
||||
toggleEmpty('tbl-leaderboard', 'empty-leaderboard', sorted.length > 0);
|
||||
}
|
||||
|
||||
// ---- QA ----
|
||||
function renderQA() {
|
||||
if (data.qaReports === null) {
|
||||
showPanelError('tbl-qa', 'empty-qa');
|
||||
return;
|
||||
}
|
||||
|
||||
const tbody = clearTbody('tbl-qa');
|
||||
const rows = data.qaReports.slice(0, 20);
|
||||
rows.forEach(function(r) {
|
||||
const score = Number(r.health_score);
|
||||
const scoreText = isNaN(score) ? '-' : score.toFixed(0);
|
||||
const cls = score > 80 ? 'score-green' : (score >= 50 ? 'score-yellow' : 'score-red');
|
||||
const scoreCell = { html: '<span class="' + cls + '">' + escapeHTML(scoreText) + '</span>' };
|
||||
tbody.appendChild(makeRow([fmtDate(r.created_at), r.repo_slug || '-', scoreCell]));
|
||||
});
|
||||
toggleEmpty('tbl-qa', 'empty-qa', rows.length > 0);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Tab switching
|
||||
// ================================================================
|
||||
|
||||
document.querySelectorAll('.tab').forEach(function(tab) {
|
||||
tab.addEventListener('click', function() {
|
||||
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
|
||||
tab.classList.add('active');
|
||||
const panelId = 'panel-' + tab.getAttribute('data-tab');
|
||||
document.getElementById(panelId).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// Event listeners
|
||||
// ================================================================
|
||||
|
||||
document.getElementById('btn-login').addEventListener('click', startLogin);
|
||||
document.getElementById('btn-signout').addEventListener('click', signOut);
|
||||
|
||||
// ================================================================
|
||||
// Init
|
||||
// ================================================================
|
||||
|
||||
(async function init() {
|
||||
// Check for OAuth callback code in URL
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const code = params.get('code');
|
||||
if (code) {
|
||||
await exchangeCode(code);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to restore existing session
|
||||
const restored = await restoreSession();
|
||||
if (restored) {
|
||||
showDashboard();
|
||||
} else {
|
||||
showLogin();
|
||||
}
|
||||
})();
|
||||
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Regression alert edge function.
|
||||
*
|
||||
* Trigger: Database webhook on eval_runs INSERT.
|
||||
* Logic: Compare new run's pass rate against recent baseline.
|
||||
* If >5% drop, POST to team's Slack webhook.
|
||||
* Dedup via alert_cooldowns table (5-min window).
|
||||
*
|
||||
* Uses service_role key (bypasses RLS) — standard for webhooks.
|
||||
*/
|
||||
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
|
||||
interface WebhookPayload {
|
||||
type: 'INSERT';
|
||||
table: string;
|
||||
record: {
|
||||
id: string;
|
||||
team_id: string;
|
||||
repo_slug: string;
|
||||
branch: string;
|
||||
passed: number;
|
||||
total_tests: number;
|
||||
timestamp: string;
|
||||
};
|
||||
schema: string;
|
||||
}
|
||||
|
||||
// --- Pure functions (testable) ---
|
||||
|
||||
export function computePassRate(passed: number, total: number): number | null {
|
||||
return total > 0 ? (passed / total) * 100 : null;
|
||||
}
|
||||
|
||||
export function shouldAlert(
|
||||
currentRate: number | null,
|
||||
baselineRate: number | null,
|
||||
thresholdPct: number = 5,
|
||||
): boolean {
|
||||
if (currentRate === null || baselineRate === null) return false;
|
||||
return baselineRate - currentRate > thresholdPct;
|
||||
}
|
||||
|
||||
export function formatSlackMessage(opts: {
|
||||
repoSlug: string;
|
||||
branch: string;
|
||||
previousRate: number;
|
||||
currentRate: number;
|
||||
}): string {
|
||||
const delta = opts.currentRate - opts.previousRate;
|
||||
const arrow = delta < 0 ? 'regressed' : 'improved';
|
||||
return [
|
||||
`:warning: *Eval ${arrow}* on \`${opts.branch}\` (${opts.repoSlug})`,
|
||||
`Pass rate: ${opts.previousRate.toFixed(0)}% → ${opts.currentRate.toFixed(0)}% (${delta > 0 ? '+' : ''}${delta.toFixed(0)}%)`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// --- Main handler ---
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
try {
|
||||
const payload: WebhookPayload = await req.json();
|
||||
const { record } = payload;
|
||||
|
||||
if (!record || !record.team_id || !record.total_tests) {
|
||||
return new Response('OK (skipped: missing fields)', { status: 200 });
|
||||
}
|
||||
|
||||
const currentRate = computePassRate(record.passed, record.total_tests);
|
||||
if (currentRate === null) {
|
||||
return new Response('OK (skipped: total_tests=0)', { status: 200 });
|
||||
}
|
||||
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
// Check cooldown (5-min dedup)
|
||||
const { data: cooldown } = await supabase
|
||||
.from('alert_cooldowns')
|
||||
.select('last_sent_at')
|
||||
.eq('team_id', record.team_id)
|
||||
.eq('repo_slug', record.repo_slug)
|
||||
.eq('alert_type', 'regression')
|
||||
.single();
|
||||
|
||||
if (cooldown?.last_sent_at) {
|
||||
const cooldownMs = Date.now() - new Date(cooldown.last_sent_at).getTime();
|
||||
if (cooldownMs < 5 * 60 * 1000) {
|
||||
return new Response('OK (cooldown active)', { status: 200 });
|
||||
}
|
||||
}
|
||||
|
||||
// Get previous runs for baseline
|
||||
const { data: previousRuns } = await supabase
|
||||
.from('eval_runs')
|
||||
.select('passed, total_tests')
|
||||
.eq('team_id', record.team_id)
|
||||
.eq('repo_slug', record.repo_slug)
|
||||
.neq('id', record.id)
|
||||
.order('timestamp', { ascending: false })
|
||||
.limit(19);
|
||||
|
||||
if (!previousRuns || previousRuns.length < 2) {
|
||||
return new Response('OK (not enough history)', { status: 200 });
|
||||
}
|
||||
|
||||
// Compute baseline pass rate
|
||||
const rates = previousRuns
|
||||
.map(r => computePassRate(r.passed, r.total_tests))
|
||||
.filter((r): r is number => r !== null);
|
||||
|
||||
if (rates.length === 0) {
|
||||
return new Response('OK (no valid baseline)', { status: 200 });
|
||||
}
|
||||
|
||||
const baselineRate = rates.reduce((a, b) => a + b, 0) / rates.length;
|
||||
|
||||
if (!shouldAlert(currentRate, baselineRate)) {
|
||||
return new Response('OK (no regression)', { status: 200 });
|
||||
}
|
||||
|
||||
// Get Slack webhook URL
|
||||
const { data: setting } = await supabase
|
||||
.from('team_settings')
|
||||
.select('value')
|
||||
.eq('team_id', record.team_id)
|
||||
.eq('key', 'slack-webhook')
|
||||
.single();
|
||||
|
||||
if (!setting?.value) {
|
||||
console.log(`Regression detected but no Slack webhook configured for team ${record.team_id}`);
|
||||
return new Response('OK (no webhook configured)', { status: 200 });
|
||||
}
|
||||
|
||||
// Send Slack alert
|
||||
const message = formatSlackMessage({
|
||||
repoSlug: record.repo_slug,
|
||||
branch: record.branch,
|
||||
previousRate: baselineRate,
|
||||
currentRate,
|
||||
});
|
||||
|
||||
const slackRes = await fetch(setting.value, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: message }),
|
||||
});
|
||||
|
||||
if (!slackRes.ok) {
|
||||
console.error(`Slack webhook failed: ${slackRes.status} ${await slackRes.text()}`);
|
||||
}
|
||||
|
||||
// Update cooldown
|
||||
await supabase
|
||||
.from('alert_cooldowns')
|
||||
.upsert({
|
||||
team_id: record.team_id,
|
||||
repo_slug: record.repo_slug,
|
||||
alert_type: 'regression',
|
||||
last_sent_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
console.log(`Regression alert sent: ${record.repo_slug} ${baselineRate.toFixed(0)}% → ${currentRate.toFixed(0)}%`);
|
||||
|
||||
return new Response('OK (alert sent)', { status: 200 });
|
||||
} catch (err) {
|
||||
console.error(`Regression alert error: ${err}`);
|
||||
return new Response('OK (error logged)', { status: 200 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Weekly digest edge function.
|
||||
*
|
||||
* Trigger: pg_cron every Monday 9am UTC.
|
||||
* Logic: For each team with digest_enabled=true, aggregate 7-day data
|
||||
* and POST a summary to their Slack webhook.
|
||||
*
|
||||
* Uses service_role key (bypasses RLS) — standard for cron functions.
|
||||
*/
|
||||
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
|
||||
// --- Pure functions (testable) ---
|
||||
|
||||
interface DigestData {
|
||||
teamSlug: string;
|
||||
evalRuns: number;
|
||||
evalPassRate: number | null;
|
||||
evalPassRateDelta: number | null;
|
||||
shipsByPerson: Array<{ email: string; count: number }>;
|
||||
totalShips: number;
|
||||
sessionCount: number;
|
||||
topTools: Array<{ tool: string; count: number }>;
|
||||
totalCost: number;
|
||||
}
|
||||
|
||||
export function formatDigestMessage(data: DigestData): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`:bar_chart: *Weekly gstack Digest* — ${data.teamSlug}`);
|
||||
lines.push('');
|
||||
|
||||
// Evals
|
||||
if (data.evalRuns > 0) {
|
||||
let evalLine = `:white_check_mark: *Evals:* ${data.evalRuns} runs`;
|
||||
if (data.evalPassRate !== null) {
|
||||
evalLine += `, ${data.evalPassRate.toFixed(0)}% pass rate`;
|
||||
if (data.evalPassRateDelta !== null) {
|
||||
const sign = data.evalPassRateDelta >= 0 ? '+' : '';
|
||||
evalLine += ` (${sign}${data.evalPassRateDelta.toFixed(0)}% from last week)`;
|
||||
}
|
||||
}
|
||||
lines.push(evalLine);
|
||||
}
|
||||
|
||||
// Ships
|
||||
if (data.totalShips > 0) {
|
||||
const people = data.shipsByPerson
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5)
|
||||
.map(p => `${p.email.split('@')[0]}: ${p.count}`)
|
||||
.join(', ');
|
||||
lines.push(`:rocket: *Ships:* ${data.totalShips} PRs (${people})`);
|
||||
}
|
||||
|
||||
// Sessions
|
||||
if (data.sessionCount > 0) {
|
||||
let sessionLine = `:robot_face: *AI Sessions:* ${data.sessionCount}`;
|
||||
if (data.topTools.length > 0) {
|
||||
const tools = data.topTools.slice(0, 5).map(t => `${t.tool}(${t.count})`).join(', ');
|
||||
sessionLine += ` — top tools: ${tools}`;
|
||||
}
|
||||
lines.push(sessionLine);
|
||||
}
|
||||
|
||||
// Cost
|
||||
if (data.totalCost > 0) {
|
||||
lines.push(`:moneybag: *Eval spend:* $${data.totalCost.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// Quiet week fallback
|
||||
if (data.evalRuns === 0 && data.totalShips === 0 && data.sessionCount === 0) {
|
||||
lines.push('_Quiet week — no evals, ships, or sessions recorded._');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// --- Main handler ---
|
||||
|
||||
Deno.serve(async (_req: Request) => {
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
const weekAgo = new Date(Date.now() - 7 * 86_400_000).toISOString();
|
||||
const twoWeeksAgo = new Date(Date.now() - 14 * 86_400_000).toISOString();
|
||||
|
||||
// Find all teams with digest enabled
|
||||
const { data: digestSettings } = await supabase
|
||||
.from('team_settings')
|
||||
.select('team_id, value')
|
||||
.eq('key', 'digest-enabled')
|
||||
.eq('value', 'true');
|
||||
|
||||
if (!digestSettings || digestSettings.length === 0) {
|
||||
console.log('No teams have digest enabled');
|
||||
return new Response('OK (no teams)', { status: 200 });
|
||||
}
|
||||
|
||||
let sentCount = 0;
|
||||
|
||||
for (const setting of digestSettings) {
|
||||
const teamId = setting.team_id;
|
||||
|
||||
// Get Slack webhook
|
||||
const { data: webhookSetting } = await supabase
|
||||
.from('team_settings')
|
||||
.select('value')
|
||||
.eq('team_id', teamId)
|
||||
.eq('key', 'slack-webhook')
|
||||
.single();
|
||||
|
||||
if (!webhookSetting?.value) {
|
||||
console.log(`Team ${teamId}: digest enabled but no Slack webhook`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get team slug
|
||||
const { data: team } = await supabase
|
||||
.from('teams')
|
||||
.select('slug')
|
||||
.eq('id', teamId)
|
||||
.single();
|
||||
|
||||
// Fetch this week's data
|
||||
const [evalRes, shipRes, sessionRes] = await Promise.all([
|
||||
supabase.from('eval_runs')
|
||||
.select('passed, total_tests, total_cost_usd, user_id')
|
||||
.eq('team_id', teamId)
|
||||
.gte('timestamp', weekAgo),
|
||||
supabase.from('ship_logs')
|
||||
.select('user_id, email')
|
||||
.eq('team_id', teamId)
|
||||
.gte('created_at', weekAgo),
|
||||
supabase.from('session_transcripts')
|
||||
.select('tools_used')
|
||||
.eq('team_id', teamId)
|
||||
.gte('started_at', weekAgo),
|
||||
]);
|
||||
|
||||
const evalRuns = evalRes.data || [];
|
||||
const shipLogs = shipRes.data || [];
|
||||
const sessions = sessionRes.data || [];
|
||||
|
||||
// Compute pass rate
|
||||
let passRate: number | null = null;
|
||||
const validRuns = evalRuns.filter(r => r.total_tests > 0);
|
||||
if (validRuns.length > 0) {
|
||||
const totalPassed = validRuns.reduce((s, r) => s + r.passed, 0);
|
||||
const totalTests = validRuns.reduce((s, r) => s + r.total_tests, 0);
|
||||
passRate = totalTests > 0 ? (totalPassed / totalTests) * 100 : null;
|
||||
}
|
||||
|
||||
// Compute previous week's pass rate for delta
|
||||
let passRateDelta: number | null = null;
|
||||
const { data: prevWeekRuns } = await supabase
|
||||
.from('eval_runs')
|
||||
.select('passed, total_tests')
|
||||
.eq('team_id', teamId)
|
||||
.gte('timestamp', twoWeeksAgo)
|
||||
.lt('timestamp', weekAgo);
|
||||
|
||||
if (prevWeekRuns && prevWeekRuns.length > 0 && passRate !== null) {
|
||||
const prevValid = prevWeekRuns.filter(r => r.total_tests > 0);
|
||||
if (prevValid.length > 0) {
|
||||
const prevPassed = prevValid.reduce((s, r) => s + r.passed, 0);
|
||||
const prevTotal = prevValid.reduce((s, r) => s + r.total_tests, 0);
|
||||
const prevRate = prevTotal > 0 ? (prevPassed / prevTotal) * 100 : null;
|
||||
if (prevRate !== null) passRateDelta = passRate - prevRate;
|
||||
}
|
||||
}
|
||||
|
||||
// Ships by person
|
||||
const shipsByPerson = new Map<string, number>();
|
||||
for (const log of shipLogs) {
|
||||
const key = String(log.email || log.user_id || 'unknown');
|
||||
shipsByPerson.set(key, (shipsByPerson.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
// Top tools from sessions
|
||||
const toolCounts = new Map<string, number>();
|
||||
for (const s of sessions) {
|
||||
const tools = (s.tools_used as string[]) || [];
|
||||
for (const t of tools) {
|
||||
toolCounts.set(t, (toolCounts.get(t) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const totalCost = evalRuns.reduce((s, r) => s + (Number(r.total_cost_usd) || 0), 0);
|
||||
|
||||
const digest: DigestData = {
|
||||
teamSlug: team?.slug || 'unknown',
|
||||
evalRuns: evalRuns.length,
|
||||
evalPassRate: passRate,
|
||||
evalPassRateDelta: passRateDelta,
|
||||
shipsByPerson: [...shipsByPerson.entries()].map(([email, count]) => ({ email, count })),
|
||||
totalShips: shipLogs.length,
|
||||
sessionCount: sessions.length,
|
||||
topTools: [...toolCounts.entries()]
|
||||
.map(([tool, count]) => ({ tool, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
totalCost,
|
||||
};
|
||||
|
||||
const message = formatDigestMessage(digest);
|
||||
|
||||
// Send to Slack
|
||||
const slackRes = await fetch(webhookSetting.value, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: message }),
|
||||
});
|
||||
|
||||
if (slackRes.ok) {
|
||||
sentCount++;
|
||||
console.log(`Digest sent for team ${team?.slug || teamId}`);
|
||||
} else {
|
||||
console.error(`Slack failed for team ${teamId}: ${slackRes.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(`OK (${sentCount} digests sent)`, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error(`Weekly digest error: ${err}`);
|
||||
return new Response('OK (error logged)', { status: 200 });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Tests for dashboard UI HTML generation.
|
||||
*/
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { getDashboardHTML } from '../supabase/functions/dashboard/ui';
|
||||
|
||||
const SUPABASE_URL = 'https://test-project.supabase.co';
|
||||
const ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.test-anon-key';
|
||||
|
||||
describe('getDashboardHTML', () => {
|
||||
const html = getDashboardHTML(SUPABASE_URL, ANON_KEY);
|
||||
|
||||
test('returns valid HTML document', () => {
|
||||
expect(html).toContain('<!DOCTYPE html>');
|
||||
expect(html).toContain('<html');
|
||||
expect(html).toContain('</html>');
|
||||
});
|
||||
|
||||
test('contains page title', () => {
|
||||
expect(html).toContain('<title>gstack Dashboard</title>');
|
||||
});
|
||||
|
||||
test('embeds supabase URL', () => {
|
||||
expect(html).toContain(SUPABASE_URL);
|
||||
});
|
||||
|
||||
test('embeds anon key', () => {
|
||||
expect(html).toContain(ANON_KEY);
|
||||
});
|
||||
|
||||
test('contains login UI elements', () => {
|
||||
expect(html).toContain('Sign in with GitHub');
|
||||
});
|
||||
|
||||
test('contains tab navigation', () => {
|
||||
expect(html).toContain('Overview');
|
||||
expect(html).toContain('Evals');
|
||||
expect(html).toContain('Ships');
|
||||
expect(html).toContain('Costs');
|
||||
expect(html).toContain('Leaderboard');
|
||||
expect(html).toContain('QA');
|
||||
});
|
||||
|
||||
test('contains auto-refresh logic', () => {
|
||||
expect(html).toContain('visibilitychange');
|
||||
expect(html).toContain('setInterval');
|
||||
});
|
||||
|
||||
test('contains PKCE auth code', () => {
|
||||
expect(html).toContain('code_challenge');
|
||||
expect(html).toContain('code_verifier');
|
||||
});
|
||||
|
||||
test('uses textContent for XSS prevention', () => {
|
||||
expect(html).toContain('textContent');
|
||||
});
|
||||
|
||||
test('contains dark theme styling', () => {
|
||||
expect(html).toContain('#0a0a0a');
|
||||
});
|
||||
|
||||
test('contains SVG chart elements', () => {
|
||||
expect(html).toContain('svg');
|
||||
});
|
||||
|
||||
test('fetches from eval_runs endpoint', () => {
|
||||
expect(html).toContain('eval_runs');
|
||||
});
|
||||
|
||||
test('fetches from ship_logs endpoint', () => {
|
||||
expect(html).toContain('ship_logs');
|
||||
});
|
||||
|
||||
test('fetches from sync_heartbeats for who\'s online', () => {
|
||||
expect(html).toContain('sync_heartbeats');
|
||||
});
|
||||
|
||||
test('contains sign out functionality', () => {
|
||||
expect(html).toContain('Sign out');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user