diff --git a/src/components/admin/AdminMultiLineChart.vue b/src/components/admin/AdminMultiLineChart.vue index 4506c2f101..13d2f82b6a 100644 --- a/src/components/admin/AdminMultiLineChart.vue +++ b/src/components/admin/AdminMultiLineChart.vue @@ -15,7 +15,7 @@ import { import { computed } from 'vue' import { Line } from 'vue-chartjs' import { createChartColorWithOpacity, resolveAccessibleChartColor } from '~/services/chartConfig' -import { formatLocalDate, formatLocalMonthYear } from '~/services/date' +import { formatLocalDate, formatLocalDateTime, formatLocalMonthYear } from '~/services/date' import { formatNumberValue } from '~/services/formatLocale' interface DataSeries { @@ -34,7 +34,7 @@ const props = defineProps({ default: false, }, dateGranularity: { - type: String as () => 'day' | 'month', + type: String as () => 'day' | 'month' | 'hour', default: 'day', }, valuePrefix: { @@ -67,6 +67,11 @@ function formatChartDate(date: string) { if (formattedMonth) return formattedMonth } + if (props.dateGranularity === 'hour') { + const formattedHour = formatLocalDateTime(date) + if (formattedHour) + return formattedHour + } return formatLocalDate(date) || date } diff --git a/src/pages/admin/dashboard/builder.vue b/src/pages/admin/dashboard/builder.vue index fae2164702..44313fd381 100644 --- a/src/pages/admin/dashboard/builder.vue +++ b/src/pages/admin/dashboard/builder.vue @@ -100,6 +100,29 @@ interface BuilderAnalytics { posthog_connected: boolean } +interface BuilderCapacityLive { + workers_total: number + workers_online: number + used: number + free: number + waiting: number + offline: number + builder_reachable: boolean +} +interface BuilderCapacityHourPoint { + date: string + workers: number + used: number + free: number + waiting: number +} +interface BuilderCapacity { + live: BuilderCapacityLive + hourly: BuilderCapacityHourPoint[] + capacity_events: number + runs_sampled: number +} + const { t } = useI18n() const displayStore = useDisplayStore() const mainStore = useMainStore() @@ -241,6 +264,37 @@ function buildPeriodSubtitle(stats: { builds: number, days: number, totalSeconds return `${formatNumberValue(stats.builds)} builds across ${formatNumberValue(stats.days)} active days, ${formatTotalSeconds(stats.totalSeconds)} total in selected period` } +// ---- builder capacity (live pool + hourly free/used) ---- +const isLoadingCapacity = ref(false) +const capacity = ref(null) + +async function loadCapacity() { + isLoadingCapacity.value = true + try { + capacity.value = (await adminStore.fetchStats('builder_capacity')) || null + } + catch (error) { + console.error('[Admin Builder] Error loading builder capacity:', error) + capacity.value = null + } + finally { + isLoadingCapacity.value = false + } +} + +const capacityLive = computed(() => capacity.value?.live) +const capacityHourlySeries = computed(() => { + const hourly = capacity.value?.hourly ?? [] + if (!hourly.length) + return [] + return [ + { label: 'Workers', color: '#64748b', data: hourly.map(d => ({ date: d.date, value: d.workers })) }, + { label: 'Used', color: '#ef4444', data: hourly.map(d => ({ date: d.date, value: d.used })) }, + { label: 'Free', color: '#10b981', data: hourly.map(d => ({ date: d.date, value: d.free })) }, + ] +}) +const hasCapacityHourly = computed(() => capacityHourlySeries.value.some(s => s.data.some(p => p.value > 0))) + // ---- builder onboarding analytics (builder_analytics) ---- const isLoadingData = ref(false) const data = ref(null) @@ -349,7 +403,7 @@ async function spoof(orgId: string) { // ---- shared lifecycle ---- async function loadAll() { - await Promise.all([loadGlobalStatsTrend(), loadData()]) + await Promise.all([loadCapacity(), loadGlobalStatsTrend(), loadData()]) } function sendNonAdminBack() { @@ -388,6 +442,70 @@ displayStore.defaultBack = '/dashboard'
+ +
+ + + + + +
+ +
+ + + + +
+
{ result = await getAdminBuilderAnalytics(c, start_date, end_date) break + case 'builder_capacity': + result = await getAdminBuilderCapacity(c, start_date, end_date) + break + default: throw simpleError('invalid_metric_category', 'Invalid metric category', { metric_category }) } diff --git a/supabase/functions/_backend/public/build/start.ts b/supabase/functions/_backend/public/build/start.ts index 5e6bdf0dda..148301da18 100644 --- a/supabase/functions/_backend/public/build/start.ts +++ b/supabase/functions/_backend/public/build/start.ts @@ -321,6 +321,7 @@ export async function startBuild( .from('build_requests') .update({ status: startedStatus, + started_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) .eq('builder_job_id', jobId) diff --git a/supabase/functions/_backend/public/build/status.ts b/supabase/functions/_backend/public/build/status.ts index 1452b3088a..a650f59805 100644 --- a/supabase/functions/_backend/public/build/status.ts +++ b/supabase/functions/_backend/public/build/status.ts @@ -11,6 +11,7 @@ import { shouldApplyBuildTimeout, } from '../../utils/build_timeout.ts' import { emitBuildTransitionEvent } from '../../utils/build_tracking.ts' +import { isoFromBuilderTimestamp } from '../../utils/builder_capacity.ts' import { simpleError } from '../../utils/hono.ts' import { cloudlog, cloudlogErr } from '../../utils/logging.ts' import { checkPermission } from '../../utils/rbac.ts' @@ -223,6 +224,8 @@ export async function getBuildStatus( status: effectiveStatus, last_error: effectiveError, runner_wait_seconds: runnerWaitSeconds, + started_at: isoFromBuilderTimestamp(builderJob.job.started_at) ?? undefined, + completed_at: isoFromBuilderTimestamp(effectiveCompletedAt) ?? undefined, updated_at: new Date().toISOString(), }) .eq('builder_job_id', job_id) diff --git a/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts b/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts index 462abd9561..fe17127095 100644 --- a/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts +++ b/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts @@ -13,6 +13,7 @@ import { TERMINAL_BUILD_STATUSES, } from '../utils/build_timeout.ts' import { emitBuildTransitionEvent } from '../utils/build_tracking.ts' +import { isoFromBuilderTimestamp } from '../utils/builder_capacity.ts' import { BRES, middlewareAPISecret } from '../utils/hono.ts' import { cloudlog, cloudlogErr } from '../utils/logging.ts' import { recordBuildTime, supabaseAdmin } from '../utils/supabase.ts' @@ -225,6 +226,8 @@ app.post('/', middlewareAPISecret, async (c) => { status: effectiveStatus, last_error: effectiveError, runner_wait_seconds: runnerWaitSeconds, + started_at: isoFromBuilderTimestamp(builderJob.job.started_at) ?? undefined, + completed_at: isoFromBuilderTimestamp(effectiveCompletedAt) ?? undefined, updated_at: new Date().toISOString(), }) .eq('id', build.id) diff --git a/supabase/functions/_backend/utils/builder_capacity.ts b/supabase/functions/_backend/utils/builder_capacity.ts new file mode 100644 index 0000000000..066e6c8910 --- /dev/null +++ b/supabase/functions/_backend/utils/builder_capacity.ts @@ -0,0 +1,471 @@ +import type { Context } from 'hono' +import { cloudlog, cloudlogErr } from './logging.ts' +import { closeClient, getPgClient } from './pg.ts' +import { supabaseAdmin } from './supabase.ts' +import { getEnv } from './utils.ts' + +export interface BuilderCapacityEvent { + created_at: number + workers_total: number + delta: number +} + +export interface BuilderRunInterval { + started_at: number + completed_at: number | null +} + +export interface BuilderCapacityHourPoint { + date: string + workers: number + used: number + free: number + waiting: number +} + +export interface BuilderCapacityLive { + workers_total: number + workers_online: number + used: number + free: number + waiting: number + offline: number + builder_reachable: boolean +} + +export interface BuilderCapacityResult { + live: BuilderCapacityLive + hourly: BuilderCapacityHourPoint[] + capacity_events: number + runs_sampled: number +} + +interface BuilderRunner { + id?: number | string + online?: boolean + currentJobId?: string | null +} + +interface BuilderRunnersResponse { + runners?: BuilderRunner[] + pressure?: { + waitingJobs?: number + onlineRunners?: number + registeredRunners?: number + } +} + +interface BuilderOkResponse { + machines_set?: number + machines_answering?: number + status?: string +} + +const ACTIVE_BUILD_STATUSES = ['starting', 'waiting_runner', 'running'] as const +const HOUR_MS = 60 * 60 * 1000 + +export function msFromBuilderTimestamp(value: number | null | undefined): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) + return null + return Math.trunc(value) +} + +export function isoFromBuilderTimestamp(value: number | null | undefined): string | null { + const ms = msFromBuilderTimestamp(value) + if (ms === null) + return null + return new Date(ms).toISOString() +} + +export function workersAt(events: BuilderCapacityEvent[], atMs: number): number { + let workers = 0 + for (const event of events) { + if (event.created_at > atMs) + break + workers = event.workers_total + } + return workers +} + +export function maxConcurrentUsed( + intervals: BuilderRunInterval[], + hourStartMs: number, + hourEndMs: number, +): number { + const events: Array<{ t: number, d: number }> = [] + for (const interval of intervals) { + if (interval.started_at >= hourEndMs) + continue + const end = interval.completed_at ?? hourEndMs + if (end <= hourStartMs) + continue + const start = Math.max(interval.started_at, hourStartMs) + const stop = Math.min(end, hourEndMs) + if (stop <= start) + continue + events.push({ t: start, d: 1 }) + events.push({ t: stop, d: -1 }) + } + events.sort((a, b) => a.t - b.t || a.d - b.d) + + let current = 0 + let max = 0 + for (const event of events) { + current += event.d + if (current > max) + max = current + } + return max +} + +export function reconstructHourlyCapacity( + events: BuilderCapacityEvent[], + intervals: BuilderRunInterval[], + startIso: string, + endIso: string, + waitingByHour: Map = new Map(), +): BuilderCapacityHourPoint[] { + const startMs = Date.parse(startIso) + const endMs = Date.parse(endIso) + if (!Number.isFinite(startMs) || !Number.isFinite(endMs) || endMs <= startMs) + return [] + + const sortedEvents = [...events].sort((a, b) => a.created_at - b.created_at) + const hourStart = Math.floor(startMs / HOUR_MS) * HOUR_MS + const points: BuilderCapacityHourPoint[] = [] + + for (let t = hourStart; t < endMs; t += HOUR_MS) { + const hourEnd = t + HOUR_MS + const date = new Date(t).toISOString() + const workers = workersAt(sortedEvents, hourEnd - 1) + const used = maxConcurrentUsed(intervals, t, hourEnd) + const free = Math.max(0, workers - used) + points.push({ + date, + workers, + used, + free, + waiting: waitingByHour.get(date) ?? 0, + }) + } + + return points +} + +async function fetchBuilderLive(c: Context): Promise<{ + live: BuilderCapacityLive + source: string +}> { + const empty: BuilderCapacityLive = { + workers_total: 0, + workers_online: 0, + used: 0, + free: 0, + waiting: 0, + offline: 0, + builder_reachable: false, + } + + const builderUrl = getEnv(c, 'BUILDER_URL') + const builderApiKey = getEnv(c, 'BUILDER_API_KEY') + if (!builderUrl) { + return { live: empty, source: 'missing_builder_url' } + } + + try { + if (builderApiKey) { + const response = await fetch(`${builderUrl}/gitlab-emulator/runners`, { + method: 'GET', + headers: { 'x-api-key': builderApiKey }, + }) + if (response.ok) { + const body = await response.json() as BuilderRunnersResponse + const runners = body.runners ?? [] + const online = runners.filter(r => r.online) + const used = online.filter(r => !!r.currentJobId).length + const free = Math.max(0, online.length - used) + const offline = Math.max(0, runners.length - online.length) + const waiting = body.pressure?.waitingJobs ?? 0 + return { + live: { + workers_total: runners.length, + workers_online: online.length, + used, + free, + waiting, + offline, + builder_reachable: true, + }, + source: 'runners', + } + } + cloudlogErr({ + requestId: c.get('requestId'), + message: 'builder capacity runners fetch failed', + status: response.status, + }) + } + + const okResponse = await fetch(`${builderUrl}/ok`, { method: 'GET' }) + if (!okResponse.ok) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'builder capacity /ok fetch failed', + status: okResponse.status, + }) + return { live: empty, source: 'unreachable' } + } + const ok = await okResponse.json() as BuilderOkResponse + const online = Math.max(0, Math.trunc(ok.machines_answering ?? 0)) + const total = Math.max(online, Math.trunc(ok.machines_set ?? online)) + return { + live: { + workers_total: total, + workers_online: online, + used: 0, + free: online, + waiting: 0, + offline: Math.max(0, total - online), + builder_reachable: true, + }, + source: 'ok', + } + } + catch (error) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'builder capacity live fetch error', + error: String(error), + }) + return { live: empty, source: 'error' } + } +} + +async function countActiveBuilds(c: Context): Promise { + const { count, error } = await supabaseAdmin(c) + .from('build_requests') + .select('id', { count: 'exact', head: true }) + .in('status', [...ACTIVE_BUILD_STATUSES]) + + if (error) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Failed counting active builds for capacity', + error: error.message, + }) + return 0 + } + return count ?? 0 +} + +export async function recordBuilderCapacityIfChanged( + c: Context, + workersTotal: number, + source = 'sync', +): Promise { + const total = Math.max(0, Math.trunc(workersTotal)) + const admin = supabaseAdmin(c) + + const { data: latest, error: latestError } = await admin + .from('builder_capacity_events') + .select('workers_total') + .order('created_at', { ascending: false }) + .order('id', { ascending: false }) + .limit(1) + .maybeSingle() + + if (latestError) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Failed reading latest builder capacity event', + error: latestError.message, + }) + return null + } + + const previous = latest?.workers_total ?? null + if (previous === total) + return null + + const delta = previous === null ? total : total - previous + const { data: inserted, error: insertError } = await admin + .from('builder_capacity_events') + .insert({ + workers_total: total, + delta, + source, + }) + .select('created_at, workers_total, delta') + .single() + + if (insertError || !inserted) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'Failed inserting builder capacity event', + error: insertError?.message, + }) + return null + } + + cloudlog({ + requestId: c.get('requestId'), + message: 'builder capacity event recorded', + workers_total: total, + delta, + source, + }) + + return { + created_at: Date.parse(inserted.created_at), + workers_total: inserted.workers_total, + delta: inserted.delta, + } +} + +async function loadCapacityEvents( + c: Context, + startIso: string, + endIso: string, +): Promise { + const client = getPgClient(c) + try { + // Include the latest event before the window so workersAt() has a baseline. + const { rows } = await client.query<{ + created_at: string + workers_total: number + delta: number + }>( + `WITH baseline AS ( + SELECT created_at, workers_total, delta + FROM public.builder_capacity_events + WHERE created_at < $1::timestamptz + ORDER BY created_at DESC, id DESC + LIMIT 1 + ), + window_events AS ( + SELECT created_at, workers_total, delta + FROM public.builder_capacity_events + WHERE created_at >= $1::timestamptz + AND created_at <= $2::timestamptz + ) + SELECT * FROM baseline + UNION ALL + SELECT * FROM window_events + ORDER BY created_at ASC`, + [startIso, endIso], + ) + return rows.map(row => ({ + created_at: Date.parse(row.created_at), + workers_total: Number(row.workers_total) || 0, + delta: Number(row.delta) || 0, + })) + } + finally { + await closeClient(c, client) + } +} + +async function loadRunIntervals( + c: Context, + startIso: string, + endIso: string, +): Promise { + const client = getPgClient(c) + try { + const { rows } = await client.query<{ + started_at: string | null + completed_at: string | null + created_at: string + build_time_unit: number | null + source: string + }>( + `WITH request_runs AS ( + SELECT + br.started_at, + br.completed_at, + br.created_at, + NULL::bigint AS build_time_unit, + 'request'::text AS source + FROM public.build_requests br + WHERE br.started_at IS NOT NULL + AND br.started_at < $2::timestamptz + AND (br.completed_at IS NULL OR br.completed_at > $1::timestamptz) + ), + log_runs AS ( + SELECT + (bl.created_at - make_interval(secs => GREATEST(bl.build_time_unit, 0))) AS started_at, + bl.created_at AS completed_at, + bl.created_at, + bl.build_time_unit, + 'log'::text AS source + FROM public.build_logs bl + WHERE bl.created_at > $1::timestamptz + AND bl.created_at - make_interval(secs => GREATEST(bl.build_time_unit, 0)) < $2::timestamptz + AND NOT EXISTS ( + SELECT 1 + FROM public.build_requests br + WHERE br.builder_job_id = bl.build_id + AND br.started_at IS NOT NULL + ) + ) + SELECT started_at, completed_at, created_at, build_time_unit, source + FROM request_runs + UNION ALL + SELECT started_at, completed_at, created_at, build_time_unit, source + FROM log_runs`, + [startIso, endIso], + ) + + return rows + .map((row) => { + const started = row.started_at ? Date.parse(row.started_at) : NaN + if (!Number.isFinite(started)) + return null + const completed = row.completed_at ? Date.parse(row.completed_at) : null + return { + started_at: started, + completed_at: completed !== null && Number.isFinite(completed) ? completed : null, + } satisfies BuilderRunInterval + }) + .filter((row): row is BuilderRunInterval => row !== null) + } + finally { + await closeClient(c, client) + } +} + +export async function getAdminBuilderCapacity( + c: Context, + startIso: string, + endIso: string, +): Promise { + const [{ live, source }, activeBuilds] = await Promise.all([ + fetchBuilderLive(c), + countActiveBuilds(c), + ]) + + // Prefer builder machine occupancy; fall back to Capgo active jobs when /ok + // path cannot see currentJobId. + if (source === 'ok' || (!live.builder_reachable && activeBuilds > 0)) { + live.used = Math.min(live.workers_online || activeBuilds, activeBuilds) + live.free = Math.max(0, (live.workers_online || activeBuilds) - live.used) + } + + const poolSize = live.workers_online > 0 ? live.workers_online : live.workers_total + if (live.builder_reachable) + await recordBuilderCapacityIfChanged(c, poolSize, `admin_${source}`) + + const [events, intervals] = await Promise.all([ + loadCapacityEvents(c, startIso, endIso), + loadRunIntervals(c, startIso, endIso), + ]) + + const hourly = reconstructHourlyCapacity(events, intervals, startIso, endIso) + + return { + live, + hourly, + capacity_events: events.length, + runs_sampled: intervals.length, + } +} diff --git a/supabase/functions/_backend/utils/supabase.types.ts b/supabase/functions/_backend/utils/supabase.types.ts index 579ecfb14e..b6ec08db86 100644 --- a/supabase/functions/_backend/utils/supabase.types.ts +++ b/supabase/functions/_backend/utils/supabase.types.ts @@ -584,6 +584,30 @@ export type Database = { }, ] } + builder_capacity_events: { + Row: { + created_at: string + delta: number + id: number + source: string + workers_total: number + } + Insert: { + created_at?: string + delta: number + id?: number + source?: string + workers_total: number + } + Update: { + created_at?: string + delta?: number + id?: number + source?: string + workers_total?: number + } + Relationships: [] + } build_requests: { Row: { ai_analyzed: boolean @@ -591,6 +615,7 @@ export type Database = { build_config: Json | null build_mode: string builder_job_id: string | null + completed_at: string | null created_at: string id: string last_error: string | null @@ -598,6 +623,7 @@ export type Database = { platform: string requested_by: string runner_wait_seconds: number + started_at: string | null status: string updated_at: string upload_expires_at: string @@ -611,6 +637,7 @@ export type Database = { build_config?: Json | null build_mode?: string builder_job_id?: string | null + completed_at?: string | null created_at?: string id?: string last_error?: string | null @@ -618,6 +645,7 @@ export type Database = { platform: string requested_by: string runner_wait_seconds?: number + started_at?: string | null status?: string updated_at?: string upload_expires_at: string @@ -631,6 +659,7 @@ export type Database = { build_config?: Json | null build_mode?: string builder_job_id?: string | null + completed_at?: string | null created_at?: string id?: string last_error?: string | null @@ -638,6 +667,7 @@ export type Database = { platform?: string requested_by?: string runner_wait_seconds?: number + started_at?: string | null status?: string updated_at?: string upload_expires_at?: string diff --git a/supabase/migrations/20260806202503_builder_capacity_events.sql b/supabase/migrations/20260806202503_builder_capacity_events.sql new file mode 100644 index 0000000000..924b9ef0a1 --- /dev/null +++ b/supabase/migrations/20260806202503_builder_capacity_events.sql @@ -0,0 +1,65 @@ +-- Builder pool capacity as an event log (+/− workers) plus build run intervals +-- on build_requests. Hourly free/used is reconstructed from these events — +-- no polling cron snapshots. + +ALTER TABLE public.build_requests + ADD COLUMN IF NOT EXISTS started_at timestamp with time zone, + ADD COLUMN IF NOT EXISTS completed_at timestamp with time zone; + +COMMENT ON COLUMN public.build_requests.started_at IS + 'Builder-reported job start (UTC). Used for capacity reconstruction.'; +COMMENT ON COLUMN public.build_requests.completed_at IS + 'Builder-reported job completion (UTC). Used for capacity reconstruction.'; + +CREATE INDEX IF NOT EXISTS idx_build_requests_started_at + ON public.build_requests USING btree (started_at) + WHERE started_at IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_build_requests_run_interval + ON public.build_requests USING btree (started_at, completed_at) + WHERE started_at IS NOT NULL; + +CREATE TABLE IF NOT EXISTS public.builder_capacity_events ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp with time zone DEFAULT now() NOT NULL, + workers_total integer NOT NULL, + delta integer NOT NULL, + source text NOT NULL DEFAULT 'sync', + CONSTRAINT builder_capacity_events_workers_total_check CHECK (workers_total >= 0), + CONSTRAINT builder_capacity_events_source_check CHECK ( + char_length(source) > 0 + AND char_length(source) <= 64 + ) +); + +ALTER TABLE public.builder_capacity_events OWNER TO postgres; + +COMMENT ON TABLE public.builder_capacity_events IS + 'Absolute worker pool size over time. delta is workers_total - previous workers_total.'; + +CREATE INDEX IF NOT EXISTS idx_builder_capacity_events_created_at + ON public.builder_capacity_events USING btree (created_at DESC); + +ALTER TABLE public.builder_capacity_events ENABLE ROW LEVEL SECURITY; + +-- System-managed: only service_role may read/write. Platform admin stats use +-- the service-role path in admin_stats, never PostgREST user context. +CREATE POLICY "Deny all authenticated on builder_capacity_events" + ON public.builder_capacity_events + AS RESTRICTIVE + FOR ALL + TO authenticated, anon + USING (false) + WITH CHECK (false); + +CREATE POLICY "Service role manages builder_capacity_events" + ON public.builder_capacity_events + TO service_role + USING (true) + WITH CHECK (true); + +GRANT ALL ON TABLE public.builder_capacity_events TO service_role; +GRANT USAGE, SELECT ON SEQUENCE public.builder_capacity_events_id_seq TO service_role; +REVOKE ALL ON TABLE public.builder_capacity_events FROM PUBLIC; +REVOKE ALL ON TABLE public.builder_capacity_events FROM anon; +REVOKE ALL ON TABLE public.builder_capacity_events FROM authenticated; diff --git a/tests/admin-stats.unit.test.ts b/tests/admin-stats.unit.test.ts index 3b102176d7..71a0f752dc 100644 --- a/tests/admin-stats.unit.test.ts +++ b/tests/admin-stats.unit.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' import { adminStatsBodySchema, MAX_ADMIN_STATS_LIMIT, MAX_ADMIN_STATS_OFFSET } from '../supabase/functions/_backend/private/admin_stats.ts' -import { safeParseSchema } from '../supabase/functions/_backend/utils/schema_validation.ts' import { buildPluginBreakdownResult, normalizeAnalyticsLimit } from '../supabase/functions/_backend/utils/cloudflare.ts' +import { normalizeAdminStatsDate } from '../supabase/functions/_backend/utils/pg.ts' + +import { safeParseSchema } from '../supabase/functions/_backend/utils/schema_validation.ts' describe('admin stats validation', () => { const baseBody = { @@ -49,6 +51,15 @@ describe('admin stats validation', () => { expect(parsed.success).toBe(true) }) + it('accepts the builder capacity metric', () => { + const parsed = safeParseSchema(adminStatsBodySchema, { + ...baseBody, + metric_category: 'builder_capacity', + }) + + expect(parsed.success).toBe(true) + }) + it.concurrent('accepts the trial plan breakdown metric', () => { const parsed = safeParseSchema(adminStatsBodySchema, { ...baseBody, @@ -147,8 +158,6 @@ describe('buildPluginBreakdownResult', () => { }) }) -import { normalizeAdminStatsDate } from '../supabase/functions/_backend/utils/pg.ts' - describe('normalizeAdminStatsDate', () => { it.concurrent('normalizes Date objects and ISO timestamps to YYYY-MM-DD', () => { expect(normalizeAdminStatsDate(new Date('2026-06-20T15:30:00.000Z'))).toBe('2026-06-20') diff --git a/tests/builder-capacity.unit.test.ts b/tests/builder-capacity.unit.test.ts new file mode 100644 index 0000000000..6f442f127c --- /dev/null +++ b/tests/builder-capacity.unit.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { + maxConcurrentUsed, + reconstructHourlyCapacity, + workersAt, +} from '../supabase/functions/_backend/utils/builder_capacity.ts' + +describe('builder capacity reconstruction', () => { + it.concurrent('workersAt uses the latest event at or before the timestamp', () => { + const events = [ + { created_at: Date.parse('2026-08-06T10:00:00.000Z'), workers_total: 2, delta: 2 }, + { created_at: Date.parse('2026-08-06T12:00:00.000Z'), workers_total: 3, delta: 1 }, + { created_at: Date.parse('2026-08-06T14:00:00.000Z'), workers_total: 1, delta: -2 }, + ] + expect(workersAt(events, Date.parse('2026-08-06T09:59:59.000Z'))).toBe(0) + expect(workersAt(events, Date.parse('2026-08-06T10:00:00.000Z'))).toBe(2) + expect(workersAt(events, Date.parse('2026-08-06T12:30:00.000Z'))).toBe(3) + expect(workersAt(events, Date.parse('2026-08-06T15:00:00.000Z'))).toBe(1) + }) + + it.concurrent('maxConcurrentUsed counts overlapping runs inside an hour', () => { + const hourStart = Date.parse('2026-08-06T10:00:00.000Z') + const hourEnd = Date.parse('2026-08-06T11:00:00.000Z') + const used = maxConcurrentUsed([ + { started_at: Date.parse('2026-08-06T09:50:00.000Z'), completed_at: Date.parse('2026-08-06T10:20:00.000Z') }, + { started_at: Date.parse('2026-08-06T10:10:00.000Z'), completed_at: Date.parse('2026-08-06T10:40:00.000Z') }, + { started_at: Date.parse('2026-08-06T10:30:00.000Z'), completed_at: Date.parse('2026-08-06T11:10:00.000Z') }, + ], hourStart, hourEnd) + // 10:10-10:20: 2 concurrent, 10:30-10:40: 2 concurrent + expect(used).toBe(2) + }) + + it.concurrent('reconstructHourlyCapacity builds free = workers - used', () => { + const hourly = reconstructHourlyCapacity( + [ + { created_at: Date.parse('2026-08-06T09:00:00.000Z'), workers_total: 3, delta: 3 }, + ], + [ + { started_at: Date.parse('2026-08-06T10:05:00.000Z'), completed_at: Date.parse('2026-08-06T10:50:00.000Z') }, + { started_at: Date.parse('2026-08-06T10:20:00.000Z'), completed_at: Date.parse('2026-08-06T10:45:00.000Z') }, + ], + '2026-08-06T10:00:00.000Z', + '2026-08-06T12:00:00.000Z', + ) + expect(hourly).toHaveLength(2) + expect(hourly[0]).toMatchObject({ + date: '2026-08-06T10:00:00.000Z', + workers: 3, + used: 2, + free: 1, + }) + expect(hourly[1]).toMatchObject({ + date: '2026-08-06T11:00:00.000Z', + workers: 3, + used: 0, + free: 3, + }) + }) +})