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
9 changes: 7 additions & 2 deletions src/components/admin/AdminMultiLineChart.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -34,7 +34,7 @@ const props = defineProps({
default: false,
},
dateGranularity: {
type: String as () => 'day' | 'month',
type: String as () => 'day' | 'month' | 'hour',
default: 'day',
},
valuePrefix: {
Expand Down Expand Up @@ -67,6 +67,11 @@ function formatChartDate(date: string) {
if (formattedMonth)
return formattedMonth
}
if (props.dateGranularity === 'hour') {

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: Hourly x-axis labels use formatLocalDateTime which yields a full date+time string (dateStyle 'medium' + timeStyle 'short', e.g. "Aug 5, 2026, 10:00 PM") for every hourly point, while the chart keeps maxRotation: 0 on the x-axis. Over a multi-hour/multi-day range the long labels will crowd and overlap. Consider formatting hour labels shorter (hour-only, e.g. toLocaleTimeString with hour:'numeric'), or allowing x-axis tick rotation/autoSkip for the 'hour' granularity.

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

<comment>Hourly x-axis labels use formatLocalDateTime which yields a full date+time string (dateStyle 'medium' + timeStyle 'short', e.g. "Aug 5, 2026, 10:00 PM") for every hourly point, while the chart keeps maxRotation: 0 on the x-axis. Over a multi-hour/multi-day range the long labels will crowd and overlap. Consider formatting hour labels shorter (hour-only, e.g. toLocaleTimeString with hour:'numeric'), or allowing x-axis tick rotation/autoSkip for the 'hour' granularity.</comment>

<file context>
@@ -67,6 +67,11 @@ function formatChartDate(date: string) {
     if (formattedMonth)
       return formattedMonth
   }
+  if (props.dateGranularity === 'hour') {
+    const formattedHour = formatLocalDateTime(date)
+    if (formattedHour)
</file context>

const formattedHour = formatLocalDateTime(date)
if (formattedHour)
return formattedHour
}
return formatLocalDate(date) || date
}

Expand Down
120 changes: 119 additions & 1 deletion src/pages/admin/dashboard/builder.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<BuilderCapacity | null>(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)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: A valid period with zero workers is treated as no data because the chart guard requires a positive value, hiding the zero-capacity/outage timeline and contradicting the presence of capacity events. Base this guard on event/run presence (or equivalent metadata), not on a positive plotted value.

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

<comment>A valid period with zero workers is treated as no data because the chart guard requires a positive value, hiding the zero-capacity/outage timeline and contradicting the presence of capacity events. Base this guard on event/run presence (or equivalent metadata), not on a positive plotted value.</comment>

<file context>
@@ -241,6 +264,37 @@ function buildPeriodSubtitle(stats: { builds: number, days: number, totalSeconds
+    { 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) ----
</file context>
Suggested change
const hasCapacityHourly = computed(() => capacityHourlySeries.value.some(s => s.data.some(p => p.value > 0)))
const hasCapacityHourly = computed(() => (capacity.value?.capacity_events ?? 0) > 0 || (capacity.value?.runs_sampled ?? 0) > 0)


// ---- builder onboarding analytics (builder_analytics) ----
const isLoadingData = ref(false)
const data = ref<BuilderAnalytics | null>(null)
Expand Down Expand Up @@ -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()])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: A slow or unreachable builder request now keeps the entire Builder page behind PageLoader until the external capacity call settles, delaying the rest of the admin dashboard even though capacity has its own loading state. Start capacity loading independently of the initial page readiness so the other dashboard sections can render while the live card/chart is pending.

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

<comment>A slow or unreachable builder request now keeps the entire Builder page behind `PageLoader` until the external capacity call settles, delaying the rest of the admin dashboard even though capacity has its own loading state. Start capacity loading independently of the initial page readiness so the other dashboard sections can render while the live card/chart is pending.</comment>

<file context>
@@ -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()])
 }
 
</file context>
Suggested change
await Promise.all([loadCapacity(), loadGlobalStatsTrend(), loadData()])
void loadCapacity()
await Promise.all([loadGlobalStatsTrend(), loadData()])

}

function sendNonAdminBack() {
Expand Down Expand Up @@ -388,6 +442,70 @@ displayStore.defaultBack = '/dashboard'
<PageLoader v-if="isLoading" />

<div v-else class="space-y-6">
<!-- ===================== Live builder capacity ===================== -->
<div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-5">
<AdminStatsCard
title="Available builders"
:value="capacityLive?.free ?? 0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When the builder is unavailable, every live card shows 0 even though the response explicitly says builder_reachable: false, making an outage look like a healthy pool with no workers or jobs. Preserve the unreachable state and render an unknown value/degraded label for all live metrics until a valid snapshot is available.

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

<comment>When the builder is unavailable, every live card shows `0` even though the response explicitly says `builder_reachable: false`, making an outage look like a healthy pool with no workers or jobs. Preserve the unreachable state and render an unknown value/degraded label for all live metrics until a valid snapshot is available.</comment>

<file context>
@@ -388,6 +442,70 @@ displayStore.defaultBack = '/dashboard'
+          <div class="grid grid-cols-2 gap-4 md:grid-cols-3 xl:grid-cols-5">
+            <AdminStatsCard
+              title="Available builders"
+              :value="capacityLive?.free ?? 0"
+              color-class="text-emerald-500"
+              :is-loading="isLoadingCapacity"
</file context>

color-class="text-emerald-500"
:is-loading="isLoadingCapacity"
:subtitle="capacityLive?.builder_reachable ? `${capacityLive?.workers_online ?? 0} online` : 'Builder unreachable'"
/>
<AdminStatsCard
title="Running builders"
:value="capacityLive?.used ?? 0"
color-class="text-red-500"
:is-loading="isLoadingCapacity"
subtitle="Busy online runners"
/>
<AdminStatsCard
title="Online workers"
:value="capacityLive?.workers_online ?? 0"
color-class="text-[#119eff]"
:is-loading="isLoadingCapacity"
:subtitle="`${capacityLive?.workers_total ?? 0} registered`"
/>
<AdminStatsCard
title="Waiting jobs"
:value="capacityLive?.waiting ?? 0"
color-class="text-amber-500"
:is-loading="isLoadingCapacity"
subtitle="Queued for a runner"
/>
<AdminStatsCard
title="Offline workers"
:value="capacityLive?.offline ?? 0"
color-class="text-slate-500"
:is-loading="isLoadingCapacity"
subtitle="Registered but offline"
/>
</div>

<div class="grid grid-cols-1 gap-6">
<ChartCard
title="Builder usage by hour"
:is-loading="isLoadingCapacity"
:has-data="hasCapacityHourly"
no-data-message="No capacity events or build intervals in this period yet"
>
<template #header>
<div class="flex flex-col gap-1">
<h2 class="text-2xl font-semibold leading-tight dark:text-white text-slate-600">
Builder usage by hour
</h2>
<p class="text-xs text-slate-500 dark:text-slate-400">
Free vs used reconstructed from worker +/− events and build start/end intervals
</p>
</div>
</template>
<AdminMultiLineChart
:series="capacityHourlySeries"
:is-loading="isLoadingCapacity"
date-granularity="hour"
/>
</ChartCard>
</div>

<!-- ===================== Build volume overview (global_stats) ===================== -->
<div class="grid grid-cols-1 gap-6">
<ChartCard
Expand Down
2 changes: 1 addition & 1 deletion src/stores/adminDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from '~/services/dateRange'
import { defaultApiHost, useSupabase } from '~/services/supabase'

export type MetricCategory = 'uploads' | 'distribution' | 'failures' | 'success_rate' | 'platform_overview' | 'org_metrics' | 'mau_trend' | 'success_rate_trend' | 'apps_trend' | 'bundles_trend' | 'deployments_trend' | 'storage_trend' | 'bandwidth_trend' | 'global_stats_trend' | 'plugin_breakdown' | 'trial_organizations' | 'trial_plan_breakdown' | 'onboarding_funnel' | 'cancelled_users' | 'email_type_breakdown' | 'customer_country_breakdown' | 'organization_insights' | 'builder_analytics'
export type MetricCategory = 'uploads' | 'distribution' | 'failures' | 'success_rate' | 'platform_overview' | 'org_metrics' | 'mau_trend' | 'success_rate_trend' | 'apps_trend' | 'bundles_trend' | 'deployments_trend' | 'storage_trend' | 'bandwidth_trend' | 'global_stats_trend' | 'plugin_breakdown' | 'trial_organizations' | 'trial_plan_breakdown' | 'onboarding_funnel' | 'cancelled_users' | 'email_type_breakdown' | 'customer_country_breakdown' | 'organization_insights' | 'builder_analytics' | 'builder_capacity'

export type DateRangeMode = DateRangePreset
export const DEFAULT_DATE_RANGE_MODE = DEFAULT_DATE_RANGE_PRESET
Expand Down
30 changes: 30 additions & 0 deletions src/types/supabase.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,20 +584,46 @@ 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
app_id: string
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
owner_org: string
platform: string
requested_by: string
runner_wait_seconds: number
started_at: string | null
status: string
updated_at: string
upload_expires_at: string
Expand All @@ -611,13 +637,15 @@ 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
owner_org: string
platform: string
requested_by: string
runner_wait_seconds?: number
started_at?: string | null
status?: string
updated_at?: string
upload_expires_at: string
Expand All @@ -631,13 +659,15 @@ 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
owner_org?: string
platform?: string
requested_by?: string
runner_wait_seconds?: number
started_at?: string | null
status?: string
updated_at?: string
upload_expires_at?: string
Expand Down
10 changes: 8 additions & 2 deletions supabase/functions/_backend/private/admin_stats.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type Stripe from 'stripe'
import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import { z } from 'zod'
import { Hono } from 'hono/tiny'
import { safeParseSchema } from '../utils/schema_validation.ts'
import { z } from 'zod'
import { getAdminBuilderAnalytics } from '../utils/builder_analytics.ts'
import { getAdminBuilderCapacity } from '../utils/builder_capacity.ts'
import { getAdminAppsTrend, getAdminBandwidthTrend, getAdminBundlesTrend, getAdminDistributionMetrics, getAdminFailureMetrics, getAdminMauTrend, getAdminOrgMetrics, getAdminPlatformOverview, getAdminStorageTrend, getAdminSuccessRate, getAdminSuccessRateTrend, getAdminUploadMetrics } from '../utils/cloudflare.ts'
import { parseBody, simpleError, useCors } from '../utils/hono.ts'
import { middlewareAuth } from '../utils/hono_jwt.ts'
import { cloudlog } from '../utils/logging.ts'
import { getAdminCancelledOrganizations, getAdminCustomerCountryBreakdown, getAdminDeploymentsTrend, getAdminEmailTypeBreakdown, getAdminGlobalStatsTrend, getAdminOnboardingFunnel, getAdminOrganizationInsights, getAdminPluginBreakdown, getAdminTrialOrganizations, getAdminTrialPlanBreakdown } from '../utils/pg.ts'
import { safeParseSchema } from '../utils/schema_validation.ts'
import { getCancellationDetails } from '../utils/stripe.ts'
import { supabaseClient as useSupabaseClient } from '../utils/supabase.ts'

Expand Down Expand Up @@ -41,6 +42,7 @@ const metricCategories = [
'customer_country_breakdown',
'organization_insights',
'builder_analytics',
'builder_capacity',
] as const

const isoUtcDatetimeSchema = z.string().refine(
Expand Down Expand Up @@ -313,6 +315,10 @@ app.post('/', middlewareAuth, async (c) => {
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 })
}
Expand Down
1 change: 1 addition & 0 deletions supabase/functions/_backend/public/build/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions supabase/functions/_backend/public/build/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: This write defines started_at as the builder-reported run start, but the same column is set elsewhere to the submission wall-clock time: start.ts writes started_at: new Date().toISOString() when the build advances to startedStatus. Because this cron reconciles every non-terminal stale build (~1/min) and overwrites started_at even on ticks where no transition occurred, a queued build's stored started_at silently changes from its queue-entry time to the later run-start once a runner picks it up. reconstructHourlyCapacity/the builder_capacity SQL feed "used"/free off these started_at/completed_at intervals, so the hourly capacity numbers become non-deterministic and depend on which writer ran last and how long the build sat in the queue.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At supabase/functions/_backend/triggers/cron_reconcile_build_status.ts, line 229:

<comment>This write defines `started_at` as the builder-reported run start, but the same column is set elsewhere to the submission wall-clock time: `start.ts` writes `started_at: new Date().toISOString()` when the build advances to `startedStatus`. Because this cron reconciles every non-terminal stale build (~1/min) and overwrites `started_at` even on ticks where no transition occurred, a queued build's stored `started_at` silently changes from its queue-entry time to the later run-start once a runner picks it up. `reconstructHourlyCapacity`/the `builder_capacity` SQL feed "used"/free off these `started_at`/`completed_at` intervals, so the hourly capacity numbers become non-deterministic and depend on which writer ran last and how long the build sat in the queue.</comment>

<file context>
@@ -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(),
</file context>

completed_at: isoFromBuilderTimestamp(effectiveCompletedAt) ?? undefined,
updated_at: new Date().toISOString(),
})
.eq('id', build.id)
Expand Down
Loading