From 990a6376d4542bdb3c5383b3dbe9f6e48819ebb9 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Fri, 24 Jul 2026 14:47:39 +0300 Subject: [PATCH] feat(admin): show builder runtime status on admin Builder tab Proxy builder /ok and runner/heal/scale/job state through a platform-admin endpoint so ops can monitor Mac runners without the separate CF Access page. Co-authored-by: Cursor --- cloudflare_workers/api/index.ts | 2 + messages/en.json | 25 +- .../admin/AdminBuilderRuntimeStatus.vue | 288 ++++++++++++++++++ src/pages/admin/dashboard/builder.vue | 5 + .../_backend/private/admin_builder_status.ts | 75 +++++ supabase/functions/private/index.ts | 2 + 6 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 src/components/admin/AdminBuilderRuntimeStatus.vue create mode 100644 supabase/functions/_backend/private/admin_builder_status.ts diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index e04f7391aa..073d4cb5ca 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -3,6 +3,7 @@ import type { Bindings } from '../../supabase/functions/_backend/utils/cloudflar import { app as accept_invitation } from '../../supabase/functions/_backend/private/accept_invitation.ts' import { app as admin_credits } from '../../supabase/functions/_backend/private/admin_credits.ts' import { app as admin_stats } from '../../supabase/functions/_backend/private/admin_stats.ts' +import { app as admin_builder_status } from '../../supabase/functions/_backend/private/admin_builder_status.ts' import { app as channel_device } from '../../supabase/functions/_backend/private/channel_device.ts' import { app as channel_stats } from '../../supabase/functions/_backend/private/channel_stats.ts' import { app as native_observe_stats } from '../../supabase/functions/_backend/private/native_observe_stats.ts' @@ -127,6 +128,7 @@ appPrivate.route('/set_org_email', set_org_email) appPrivate.route('/validate_password_compliance', validate_password_compliance) appPrivate.route('/admin_credits', admin_credits) appPrivate.route('/admin_stats', admin_stats) +appPrivate.route('/admin_builder_status', admin_builder_status) appPrivate.route('/stats', stats_priv) appPrivate.route('/channel_stats', channel_stats) appPrivate.route('/native_observe_stats', native_observe_stats) diff --git a/messages/en.json b/messages/en.json index 5831fc3351..5cf7b1cf02 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2572,5 +2572,28 @@ "version-count": "Versions", "view-action-logs": "View action logs", "view-logs": "View logs", - "last-seen": "Last seen" + "last-seen": "Last seen", + "admin-builder-runtime-status": "Builder runtime status", + "admin-builder-runtime-status-help": "Live Mac runners, queue pressure, heal/scale state from builder.capgo.app", + "admin-builder-open-legacy": "Open builder admin", + "admin-builder-health": "Health", + "admin-builder-machines-answering": "{answering}/{set} runners answering", + "admin-builder-waiting-jobs": "Waiting jobs", + "admin-builder-oldest-wait": "Oldest wait {wait}", + "admin-builder-heal": "Auto-heal", + "admin-builder-scale": "Auto-scale", + "admin-builder-last-scale-day": "Last scale {day}", + "admin-builder-system": "System", + "admin-builder-connection": "Connection", + "admin-builder-work": "Work", + "admin-builder-last-poll": "Last poll", + "admin-builder-no-runners": "No registered runners", + "admin-builder-no-jobs": "No recent GitLab emulator jobs", + "refreshing": "Refreshing...", + "online": "Online", + "offline": "Offline", + "busy": "Busy", + "idle": "Idle", + "job": "Job", + "started": "Started" } diff --git a/src/components/admin/AdminBuilderRuntimeStatus.vue b/src/components/admin/AdminBuilderRuntimeStatus.vue new file mode 100644 index 0000000000..a3cc8138ea --- /dev/null +++ b/src/components/admin/AdminBuilderRuntimeStatus.vue @@ -0,0 +1,288 @@ + + + diff --git a/src/pages/admin/dashboard/builder.vue b/src/pages/admin/dashboard/builder.vue index fae2164702..5d64420b23 100644 --- a/src/pages/admin/dashboard/builder.vue +++ b/src/pages/admin/dashboard/builder.vue @@ -8,6 +8,7 @@ import { computed, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { useRouter } from 'vue-router' import AdminBarChart from '~/components/admin/AdminBarChart.vue' +import AdminBuilderRuntimeStatus from '~/components/admin/AdminBuilderRuntimeStatus.vue' import AdminFilterBar from '~/components/admin/AdminFilterBar.vue' import AdminMultiLineChart from '~/components/admin/AdminMultiLineChart.vue' import AdminStatsCard from '~/components/admin/AdminStatsCard.vue' @@ -385,6 +386,10 @@ displayStore.defaultBack = '/dashboard'
+
+ +
+
diff --git a/supabase/functions/_backend/private/admin_builder_status.ts b/supabase/functions/_backend/private/admin_builder_status.ts new file mode 100644 index 0000000000..88cae4bf06 --- /dev/null +++ b/supabase/functions/_backend/private/admin_builder_status.ts @@ -0,0 +1,75 @@ +import type { Context } from 'hono' +import type { MiddlewareKeyVariables } from '../utils/hono.ts' +import { BRES, createHono, getEnv, simpleError, useCors } from '../utils/hono.ts' +import { middlewareAuth } from '../utils/hono_middleware.ts' +import { cloudlog, cloudlogErr } from '../utils/logging.ts' +import { supabaseClient } from '../utils/supabase.ts' +import { version } from '../utils/version.ts' + +type AppContext = Context + +async function verifyPlatformAdmin(c: AppContext): Promise<{ isAdmin: boolean, userId: string | null }> { + const auth = c.get('auth') + if (!auth || !auth.userId || auth.authType !== 'jwt' || !auth.jwt) + return { isAdmin: false, userId: null } + + const userSupabase = supabaseClient(c, auth.jwt) + const { data: isAdmin, error } = await userSupabase.rpc('is_platform_admin') + if (error) { + cloudlogErr({ requestId: c.get('requestId'), message: 'admin_builder_status admin check failed', error }) + return { isAdmin: false, userId: auth.userId } + } + return { isAdmin: Boolean(isAdmin), userId: auth.userId } +} + +async function fetchBuilder(c: AppContext, path: string, options?: { allowNonOk?: boolean }): Promise { + const builderUrl = getEnv(c, 'BUILDER_URL')?.replace(/\/+$/, '') + const builderApiKey = getEnv(c, 'BUILDER_API_KEY') + if (!builderUrl || !builderApiKey) + throw simpleError('builder_not_configured', 'BUILDER_URL or BUILDER_API_KEY missing') + + const response = await fetch(`${builderUrl}${path}`, { + headers: { + 'x-api-key': builderApiKey, + 'accept': 'application/json', + }, + }) + const text = await response.text() + let body: unknown = null + try { + body = text ? JSON.parse(text) : null + } + catch { + body = { raw: text } + } + if (!response.ok && !options?.allowNonOk) { + throw simpleError('builder_status_fetch_failed', `Builder ${path} failed`, { + status: response.status, + body, + }) + } + return body +} + +export const app = createHono('', version) + +app.use('*', useCors) + +app.get('/', middlewareAuth(), async (c) => { + const { isAdmin, userId } = await verifyPlatformAdmin(c) + if (!isAdmin) + throw simpleError('not_authorized', 'Not authorized', { userId }) + + cloudlog({ requestId: c.get('requestId'), message: 'admin_builder_status', userId }) + + const [ok, runnersPayload] = await Promise.all([ + fetchBuilder(c, '/ok', { allowNonOk: true }), + fetchBuilder(c, '/gitlab-emulator/runners'), + ]) + + return c.json({ + ...BRES, + ok, + runners: runnersPayload, + }) +}) diff --git a/supabase/functions/private/index.ts b/supabase/functions/private/index.ts index b6c3e12980..a7cae4f6f1 100644 --- a/supabase/functions/private/index.ts +++ b/supabase/functions/private/index.ts @@ -1,6 +1,7 @@ import { app as accept_invitation } from '../_backend/private/accept_invitation.ts' import { app as admin_credits } from '../_backend/private/admin_credits.ts' import { app as admin_stats } from '../_backend/private/admin_stats.ts' +import { app as admin_builder_status } from '../_backend/private/admin_builder_status.ts' import { app as channel_stats } from '../_backend/private/channel_stats.ts' import { app as native_observe_stats } from '../_backend/private/native_observe_stats.ts' import { app as channel_device } from '../_backend/private/channel_device.ts' @@ -63,6 +64,7 @@ appGlobal.route('/download_link', download_link) appGlobal.route('/log_as', log_as) appGlobal.route('/admin_credits', admin_credits) appGlobal.route('/admin_stats', admin_stats) +appGlobal.route('/admin_builder_status', admin_builder_status) appGlobal.route('/stats', stats_priv) appGlobal.route('/stripe_checkout', stripe_checkout) appGlobal.route('/stripe_portal', stripe_portal)