mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-07 05:56:41 +02:00
fix: review-driven hardening — env guards, token expiry, slug validation, dashboard UX
From CEO plan review: - Edge functions: early guard on missing env vars instead of non-null assert crash - cli-team: wire isTokenExpired check (was imported but unused) - Migration 007: CHECK constraint on team slug (a-z0-9 hyphens, 2-50 chars) - Dashboard: streak badges on leaderboard, repo slug in who's-online, contextual empty states that teach, 60s refresh (was 30s) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -157,19 +157,19 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
|
||||
<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 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 eval runs yet. Push results with: gstack eval push result.json</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 ships yet. Ship your first PR with: /ship</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 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 eval data yet. Run your test suite and push results: gstack eval push result.json</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>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 ships yet. Use /ship to create PRs with full review and version bumps.</div></div>
|
||||
<div class="panel"><h3>Ships Per Person This Week</h3><div class="chart-container" id="chart-ships-per-person"></div></div>
|
||||
</div>
|
||||
|
||||
@@ -183,12 +183,12 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
|
||||
|
||||
<!-- 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 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 activity this week yet. Ship, run evals, or start a Claude session to appear here.</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 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 QA reports yet. Run /qa on a web app to generate your first health score.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,12 +394,12 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Auto-refresh (30s, pauses when tab hidden)
|
||||
// Auto-refresh (60s, pauses when tab hidden)
|
||||
// ================================================================
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh();
|
||||
refreshTimer = setInterval(() => { fetchAll(); }, 30000);
|
||||
refreshTimer = setInterval(() => { fetchAll(); }, 60000);
|
||||
}
|
||||
|
||||
function stopAutoRefresh() {
|
||||
@@ -527,7 +527,7 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
|
||||
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; }
|
||||
if (!values || values.length === 0) { el.innerHTML = '<span class="panel-empty">No data points yet. Run evals to see pass rate trends.</span>'; return; }
|
||||
|
||||
const W = 600, H = 120, PAD = 30;
|
||||
const max = Math.max(...values, 1);
|
||||
@@ -559,7 +559,7 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
|
||||
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; }
|
||||
if (!items || items.length === 0) { el.innerHTML = '<span class="panel-empty">No activity to chart yet. Ship PRs to see the breakdown.</span>'; return; }
|
||||
|
||||
const W = 600, barH = 28, gap = 6;
|
||||
const H = items.length * (barH + gap) + 20;
|
||||
@@ -583,7 +583,7 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
|
||||
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; }
|
||||
if (!items || items.length === 0) { el.innerHTML = '<span class="panel-empty">No cost data yet. Eval costs appear here after runs are pushed.</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);
|
||||
@@ -806,16 +806,40 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
|
||||
board[uid].sessions++;
|
||||
});
|
||||
}
|
||||
// Online status from heartbeats
|
||||
// Online status from heartbeats (also capture repo_slug)
|
||||
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;
|
||||
if (h.repo_slug) board[uid].repo = h.repo_slug;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Compute streak badges from ship_logs (consecutive ship days this week)
|
||||
const streaks = {};
|
||||
if (data.shipLogs) {
|
||||
const byUser = {};
|
||||
data.shipLogs.filter(function(r) { return new Date(r.created_at) >= ws; }).forEach(function(r) {
|
||||
const uid = r.user_id || 'unknown';
|
||||
if (!byUser[uid]) byUser[uid] = new Set();
|
||||
byUser[uid].add(new Date(r.created_at).toISOString().slice(0, 10));
|
||||
});
|
||||
Object.keys(byUser).forEach(function(uid) {
|
||||
const dates = Array.from(byUser[uid]).sort();
|
||||
let maxRun = 1, run = 1;
|
||||
for (let i = 1; i < dates.length; i++) {
|
||||
const prev = new Date(dates[i - 1]);
|
||||
const curr = new Date(dates[i]);
|
||||
const diffDays = Math.round((curr - prev) / (1000 * 60 * 60 * 24));
|
||||
if (diffDays === 1) { run++; if (run > maxRun) maxRun = run; }
|
||||
else { run = 1; }
|
||||
}
|
||||
streaks[uid] = maxRun;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by ships desc, then evals desc
|
||||
const sorted = Object.keys(board).map(function(uid) {
|
||||
return Object.assign({ uid: uid }, board[uid]);
|
||||
@@ -824,9 +848,12 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
|
||||
const tbody = clearTbody('tbl-leaderboard');
|
||||
sorted.forEach(function(entry, i) {
|
||||
const rate = entry.total_tests ? ((entry.passed / entry.total_tests) * 100).toFixed(0) + '%' : '-';
|
||||
const streak = streaks[entry.uid] || 0;
|
||||
const streakBadge = streak >= 5 ? '\u{1F525}\u{1F525} ' : (streak >= 3 ? '\u{1F525} ' : '');
|
||||
const displayName = entry.uid.slice(0, 8) + (entry.repo ? ' — ' + escapeHTML(entry.repo) : '');
|
||||
const nameCell = entry.online
|
||||
? { html: '<span class="online-dot"></span>' + escapeHTML(entry.uid.slice(0, 8)) }
|
||||
: entry.uid.slice(0, 8);
|
||||
? { html: '<span class="online-dot"></span>' + streakBadge + displayName }
|
||||
: { html: streakBadge + displayName };
|
||||
tbody.appendChild(makeRow([
|
||||
i + 1,
|
||||
nameCell,
|
||||
|
||||
@@ -71,8 +71,12 @@ Deno.serve(async (req: Request) => {
|
||||
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 supabaseUrl = Deno.env.get('SUPABASE_URL');
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
|
||||
if (!supabaseUrl || !serviceKey) {
|
||||
console.error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY env vars');
|
||||
return new Response('OK (missing env vars)', { status: 200 });
|
||||
}
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
// Check cooldown (5-min dedup)
|
||||
|
||||
@@ -79,8 +79,12 @@ export function formatDigestMessage(data: DigestData): string {
|
||||
|
||||
Deno.serve(async (_req: Request) => {
|
||||
try {
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL')!;
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!;
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL');
|
||||
const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
|
||||
if (!supabaseUrl || !serviceKey) {
|
||||
console.error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY env vars');
|
||||
return new Response('OK (missing env vars)', { status: 200 });
|
||||
}
|
||||
const supabase = createClient(supabaseUrl, serviceKey);
|
||||
|
||||
const weekAgo = new Date(Date.now() - 7 * 86_400_000).toISOString();
|
||||
|
||||
@@ -35,6 +35,12 @@ create policy "admin_write_settings" on team_settings
|
||||
)
|
||||
);
|
||||
|
||||
-- Add CHECK constraint on teams.slug if not already present
|
||||
do $$ begin
|
||||
alter table teams add constraint chk_team_slug check (slug ~ '^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$');
|
||||
exception when duplicate_object then null;
|
||||
end $$;
|
||||
|
||||
-- ─── alert_cooldowns ────────────────────────────────────────
|
||||
|
||||
create table if not exists alert_cooldowns (
|
||||
@@ -76,6 +82,9 @@ begin
|
||||
if team_name is null or length(trim(team_name)) = 0 then
|
||||
raise exception 'team_name cannot be empty';
|
||||
end if;
|
||||
if team_slug !~ '^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$' then
|
||||
raise exception 'team_slug must be 2-50 chars, lowercase alphanumeric and hyphens only, must start and end with alphanumeric';
|
||||
end if;
|
||||
if auth.uid() is null then
|
||||
raise exception 'must be authenticated';
|
||||
end if;
|
||||
|
||||
Reference in New Issue
Block a user