Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cloudflare_workers/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 24 additions & 1 deletion messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
288 changes: 288 additions & 0 deletions src/components/admin/AdminBuilderRuntimeStatus.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import AdminStatsCard from '~/components/admin/AdminStatsCard.vue'
import { formatLocalDateTime } from '~/services/date'
import { defaultApiHost, useSupabase } from '~/services/supabase'

interface RunnerRow {
id: number
systemId: string | null
online: boolean
busy: boolean
currentJobId: string | null
lastPollAt: number | null
}

interface JobRow {
jobId: string
status: string
createdAt: number
startedAt: number | null
error: string | null
}

interface BuilderRuntimeResponse {
status?: string
ok?: {
status?: string
machines_set?: number
machines_answering?: number
provider_enabled?: boolean
updated_at?: number
}
runners?: {
enabled?: boolean
runnerStaleMs?: number
runners?: RunnerRow[]
jobs?: JobRow[]
heal?: {
lastResult?: string | null
lastError?: string | null
lastHealAt?: number | null
offlineSince?: number | null
}
scale?: {
lastResult?: string | null
lastError?: string | null
requestedAt?: number | null
lastScaleAt?: number | null
lastScaleDay?: string | null
lastAgentDescription?: string | null
}
pressure?: {
waitingJobs?: number
onlineRunners?: number
registeredRunners?: number
oldestWaitMs?: number
}
}
error?: string
message?: string
}

const { t } = useI18n()

const isLoading = ref(false)
const errorMessage = ref<string | null>(null)
const data = ref<BuilderRuntimeResponse | null>(null)

const okStatus = computed(() => data.value?.ok?.status ?? '-')
const okColor = computed(() => {
const status = data.value?.ok?.status
if (status === 'ok')
return 'text-emerald-500'
if (status === 'degraded' || status === 'no_machines')
return 'text-rose-500'
return 'text-slate-500'
})

const runners = computed(() => data.value?.runners?.runners ?? [])
const jobs = computed(() => data.value?.runners?.jobs ?? [])
const pressure = computed(() => data.value?.runners?.pressure)
const heal = computed(() => data.value?.runners?.heal)
const scale = computed(() => data.value?.runners?.scale)

function formatEpoch(ms: number | null | undefined): string {
if (!ms)
return '-'
return formatLocalDateTime(new Date(ms).toISOString())
}

function formatDuration(ms: number | null | undefined): string {
if (ms == null || !Number.isFinite(ms))
return '-'
const minutes = Math.round(ms / 60000)
if (minutes < 60)
return `${minutes}m`
return `${Math.round(minutes / 60)}h ${minutes % 60}m`
}

async function loadStatus() {
isLoading.value = true
errorMessage.value = null
try {
const supabase = useSupabase()
const { data: { session } } = await supabase.auth.getSession()
if (!session?.access_token)
throw new Error('No active session available')

const response = await fetch(`${defaultApiHost}/private/admin_builder_status`, {
method: 'GET',
headers: {
Authorization: `Bearer ${session.access_token}`,
},
})
const payload = await response.json().catch(() => ({})) as BuilderRuntimeResponse
if (!response.ok) {
throw new Error(payload.message || payload.error || `HTTP ${response.status}`)
}
data.value = payload
}
catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
}
finally {
isLoading.value = false
}
}

onMounted(() => {
void loadStatus()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Non-admin visits to the Builder route issue an authenticated status request before the page redirects them, creating avoidable 403s and admin_builder_status audit logs. Gate this initial load on the same platform-admin state used by the parent, while retaining server-side authorization.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/components/admin/AdminBuilderRuntimeStatus.vue, line 131:

<comment>Non-admin visits to the Builder route issue an authenticated status request before the page redirects them, creating avoidable 403s and `admin_builder_status` audit logs. Gate this initial load on the same platform-admin state used by the parent, while retaining server-side authorization.</comment>

<file context>
@@ -0,0 +1,288 @@
+}
+
+onMounted(() => {
+  void loadStatus()
+})
+
</file context>

})

defineExpose({ refresh: loadStatus })
</script>

<template>
<section class="space-y-4 rounded-xl border border-slate-200 bg-white/70 p-4 dark:border-slate-700 dark:bg-slate-900/40">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-lg font-semibold text-slate-800 dark:text-white">
{{ t('admin-builder-runtime-status') }}
</h2>
<p class="text-sm text-slate-500 dark:text-slate-300">
{{ t('admin-builder-runtime-status-help') }}
</p>
</div>
<div class="flex flex-wrap gap-2">
<a
class="d-btn d-btn-outline d-btn-sm"
href="https://builder.capgo.app/"
target="_blank"
rel="noopener noreferrer"
>
{{ t('admin-builder-open-legacy') }}
</a>
<button
class="d-btn d-btn-primary d-btn-sm"
type="button"
:disabled="isLoading"
@click="loadStatus"
>
{{ isLoading ? t('refreshing') : t('refresh') }}
</button>
</div>
</div>

<div
v-if="errorMessage && !data"
class="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-200"
>
{{ errorMessage }}
</div>

<div v-else-if="data" class="space-y-4">
<div
v-if="errorMessage"
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-100"
>
{{ errorMessage }}
</div>

<div class="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<AdminStatsCard
:title="t('admin-builder-health')"
:value="okStatus"
:color-class="okColor"
:subtitle="t('admin-builder-machines-answering', {
answering: data.ok?.machines_answering ?? 0,
set: data.ok?.machines_set ?? 0,
})"
:is-loading="isLoading"
/>
<AdminStatsCard
:title="t('admin-builder-waiting-jobs')"
:value="pressure?.waitingJobs ?? 0"
:subtitle="t('admin-builder-oldest-wait', { wait: formatDuration(pressure?.oldestWaitMs) })"
color-class="text-amber-500"
:is-loading="isLoading"
/>
<AdminStatsCard
:title="t('admin-builder-heal')"
:value="heal?.lastResult || '-'"
:subtitle="heal?.lastError || formatEpoch(heal?.lastHealAt)"
color-class="text-sky-500"
:is-loading="isLoading"
/>
<AdminStatsCard
:title="t('admin-builder-scale')"
:value="scale?.lastResult || '-'"
:subtitle="scale?.lastScaleDay
? t('admin-builder-last-scale-day', { day: scale.lastScaleDay })
: (scale?.lastError || formatEpoch(scale?.requestedAt))"
color-class="text-violet-500"
:is-loading="isLoading"
/>
</div>

<div class="overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
<table class="table w-full text-sm">
<thead>
<tr class="text-left text-slate-500">
<th>{{ t('id') }}</th>
<th>{{ t('admin-builder-system') }}</th>
<th>{{ t('admin-builder-connection') }}</th>
<th>{{ t('admin-builder-work') }}</th>
<th>{{ t('job') }}</th>
<th>{{ t('admin-builder-last-poll') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="runners.length === 0">
<td colspan="6" class="text-slate-500">
{{ t('admin-builder-no-runners') }}
</td>
</tr>
<tr v-for="runner in runners" :key="runner.id">
<td>#{{ runner.id }}</td>
<td>{{ runner.systemId || '-' }}</td>
<td>
<span :class="runner.online ? 'text-emerald-500' : 'text-rose-500'">
{{ runner.online ? t('online') : t('offline') }}
</span>
</td>
<td>{{ runner.busy ? t('busy') : t('idle') }}</td>
<td class="font-mono text-xs">
{{ runner.currentJobId || '-' }}
</td>
<td>{{ formatEpoch(runner.lastPollAt) }}</td>
</tr>
</tbody>
</table>
</div>

<div class="overflow-x-auto rounded-lg border border-slate-200 dark:border-slate-700">
<table class="table w-full text-sm">
<thead>
<tr class="text-left text-slate-500">
<th>{{ t('job') }}</th>
<th>{{ t('status') }}</th>
<th>{{ t('created') }}</th>
<th>{{ t('started') }}</th>
<th>{{ t('error') }}</th>
</tr>
</thead>
<tbody>
<tr v-if="jobs.length === 0">
<td colspan="5" class="text-slate-500">
{{ t('admin-builder-no-jobs') }}
</td>
</tr>
<tr v-for="job in jobs" :key="job.jobId">
<td class="font-mono text-xs">
{{ job.jobId }}
</td>
<td>{{ job.status }}</td>
<td>{{ formatEpoch(job.createdAt) }}</td>
<td>{{ formatEpoch(job.startedAt) }}</td>
<td class="max-w-xs truncate text-rose-500">
{{ job.error || '-' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</template>
5 changes: 5 additions & 0 deletions src/pages/admin/dashboard/builder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -385,6 +386,10 @@ displayStore.defaultBack = '/dashboard'
<div class="w-full h-full px-4 pt-2 mx-auto mb-8 overflow-y-auto sm:px-6 md:pt-8 lg:px-8 max-w-9xl max-h-fit">
<AdminFilterBar />

<div class="mb-6">
<AdminBuilderRuntimeStatus />
</div>

<PageLoader v-if="isLoading" />

<div v-else class="space-y-6">
Expand Down
Loading
Loading