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 @@
+
+
+
+
+
+
+
+ {{ t('admin-builder-runtime-status') }}
+
+
+ {{ t('admin-builder-runtime-status-help') }}
+
+
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+ {{ errorMessage }}
+
+
+
+
+
+
+
+
+ | {{ t('id') }} |
+ {{ t('admin-builder-system') }} |
+ {{ t('admin-builder-connection') }} |
+ {{ t('admin-builder-work') }} |
+ {{ t('job') }} |
+ {{ t('admin-builder-last-poll') }} |
+
+
+
+
+ |
+ {{ t('admin-builder-no-runners') }}
+ |
+
+
+ | #{{ runner.id }} |
+ {{ runner.systemId || '-' }} |
+
+
+ {{ runner.online ? t('online') : t('offline') }}
+
+ |
+ {{ runner.busy ? t('busy') : t('idle') }} |
+
+ {{ runner.currentJobId || '-' }}
+ |
+ {{ formatEpoch(runner.lastPollAt) }} |
+
+
+
+
+
+
+
+
+
+ | {{ t('job') }} |
+ {{ t('status') }} |
+ {{ t('created') }} |
+ {{ t('started') }} |
+ {{ t('error') }} |
+
+
+
+
+ |
+ {{ t('admin-builder-no-jobs') }}
+ |
+
+
+ |
+ {{ job.jobId }}
+ |
+ {{ job.status }} |
+ {{ formatEpoch(job.createdAt) }} |
+ {{ formatEpoch(job.startedAt) }} |
+
+ {{ job.error || '-' }}
+ |
+
+
+
+
+
+
+
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)