diff --git a/cli/src/types/supabase.types.ts b/cli/src/types/supabase.types.ts index cf1785bbd5..02f120d172 100644 --- a/cli/src/types/supabase.types.ts +++ b/cli/src/types/supabase.types.ts @@ -591,6 +591,7 @@ export type Database = { build_config: Json | null build_mode: string builder_job_id: string | null + builder_pool: string | null created_at: string id: string last_error: string | null @@ -611,6 +612,7 @@ export type Database = { build_config?: Json | null build_mode?: string builder_job_id?: string | null + builder_pool?: string | null created_at?: string id?: string last_error?: string | null @@ -631,6 +633,7 @@ export type Database = { build_config?: Json | null build_mode?: string builder_job_id?: string | null + builder_pool?: string | null created_at?: string id?: string last_error?: string | null @@ -1371,6 +1374,85 @@ export type Database = { } Relationships: [] } + + dedicated_builders: { + Row: { + activated_at: string | null + allow_shared_fallback: boolean + cancelled_at: string | null + created_at: string + id: string + monthly_builds_estimate: number | null + org_id: string + platforms: string[] + pool_id: string | null + requested_by: string | null + status: string + suspended_at: string | null + updated_at: string + use_case: string | null + worker_current_job_id: string | null + worker_last_seen_at: string | null + worker_name: string | null + worker_status: string + } + Insert: { + activated_at?: string | null + allow_shared_fallback?: boolean + cancelled_at?: string | null + created_at?: string + id?: string + monthly_builds_estimate?: number | null + org_id: string + platforms?: string[] + pool_id?: string | null + requested_by?: string | null + status?: string + suspended_at?: string | null + updated_at?: string + use_case?: string | null + worker_current_job_id?: string | null + worker_last_seen_at?: string | null + worker_name?: string | null + worker_status?: string + } + Update: { + activated_at?: string | null + allow_shared_fallback?: boolean + cancelled_at?: string | null + created_at?: string + id?: string + monthly_builds_estimate?: number | null + org_id?: string + platforms?: string[] + pool_id?: string | null + requested_by?: string | null + status?: string + suspended_at?: string | null + updated_at?: string + use_case?: string | null + worker_current_job_id?: string | null + worker_last_seen_at?: string | null + worker_name?: string | null + worker_status?: string + } + Relationships: [ + { + foreignKeyName: "dedicated_builders_org_id_fkey" + columns: ["org_id"] + isOneToOne: true + referencedRelation: "orgs" + referencedColumns: ["id"] + }, + { + foreignKeyName: "dedicated_builders_requested_by_fkey" + columns: ["requested_by"] + isOneToOne: false + referencedRelation: "users" + referencedColumns: ["id"] + }, + ] + } deploy_history: { Row: { app_id: string diff --git a/cloudflare_workers/api/index.ts b/cloudflare_workers/api/index.ts index e04f7391aa..5fde4a4f7b 100644 --- a/cloudflare_workers/api/index.ts +++ b/cloudflare_workers/api/index.ts @@ -11,6 +11,7 @@ import { app as config } from '../../supabase/functions/_backend/private/config. import { app as configBuilder } from '../../supabase/functions/_backend/private/config_builder.ts' import { app as create_device } from '../../supabase/functions/_backend/private/create_device.ts' import { app as credits } from '../../supabase/functions/_backend/private/credits.ts' +import { app as dedicated_builder } from '../../supabase/functions/_backend/private/dedicated_builder.ts' import { app as deleted_failed_version } from '../../supabase/functions/_backend/private/delete_failed_version.ts' import { app as devices_priv } from '../../supabase/functions/_backend/private/devices.ts' import { app as events } from '../../supabase/functions/_backend/private/events.ts' @@ -113,6 +114,7 @@ const functionNamePrivate = 'private' const appPrivate = createHono(functionNamePrivate, version) appPrivate.route('/plans', plans) appPrivate.route('/credits', credits) +appPrivate.route('/dedicated_builder', dedicated_builder) appPrivate.route('/store_top', storeTop) appPrivate.route('/website_stats', publicStats) appPrivate.route('/config', config) diff --git a/messages/en.json b/messages/en.json index 9555f3565b..886304a89b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -2559,7 +2559,6 @@ "error-categories": "Error categories", "error-share": "{share} of errors", "errors-in-period": "Errors in period", - "events": "Events", "failed-to-fetch-log-insights": "Failed to fetch log insights", "log-insights": "Insights", "log-insights-period-help": "Error categories, affected devices, and trends use this period.", @@ -2573,5 +2572,69 @@ "version-count": "Versions", "view-action-logs": "View action logs", "view-logs": "View logs", - "last-seen": "Last seen" + "last-seen": "Last seen", + "dedicated-builder": "Dedicated builder", + "dedicated-builder-page-subtitle": "Request a Capgo native builder worker dedicated to your organization.", + "dedicated-builder-hero-title": "Your own native build worker", + "dedicated-builder-hero-desc": "Skip the shared queue with a dedicated builder pool. When your worker is busy, builds can fall back to Capgo's shared workers.", + "dedicated-builder-benefit-queue": "Priority on your own pool", + "dedicated-builder-benefit-worker": "One worker reserved for your org", + "dedicated-builder-benefit-fallback": "Shared pool fallback when busy", + "dedicated-builder-request-title": "Request a dedicated builder", + "dedicated-builder-use-case": "What will you use it for?", + "dedicated-builder-use-case-placeholder": "e.g. CI builds for iOS/Android releases every day", + "dedicated-builder-monthly-estimate": "Estimated native builds per month", + "dedicated-builder-monthly-estimate-placeholder": "e.g. 40", + "dedicated-builder-platforms": "Platforms", + "dedicated-builder-platforms-required": "Select at least one platform", + "dedicated-builder-request-cta": "Request dedicated builder", + "dedicated-builder-need-permission": "Why can't I request this?", + "dedicated-builder-request-success": "Dedicated builder requested. Our team will provision it and update this page when it is ready.", + "dedicated-builder-request-error": "Could not submit the dedicated builder request", + "dedicated-builder-already-exists": "A dedicated builder request already exists for this organization", + "dedicated-builder-load-error": "Could not load dedicated builder status", + "dedicated-builder-requested-title": "Request received", + "dedicated-builder-requested-desc": "Our team is reviewing your request and will provision a dedicated worker for your organization.", + "dedicated-builder-provisioning-title": "Provisioning your worker", + "dedicated-builder-provisioning-desc": "Your dedicated builder is being set up. Builds will start using it once it becomes active.", + "dedicated-builder-requested-at": "Requested on {date}", + "dedicated-builder-step-requested": "Request submitted", + "dedicated-builder-step-provisioning": "Worker provisioning", + "dedicated-builder-step-active": "Dedicated pool active", + "dedicated-builder-cancel-cta": "Cancel request", + "dedicated-builder-cancel-title": "Cancel dedicated builder request?", + "dedicated-builder-cancel-description": "You can request a dedicated builder again later.", + "dedicated-builder-cancel-confirm": "Cancel request", + "dedicated-builder-cancel-success": "Dedicated builder request cancelled", + "dedicated-builder-cancel-error": "Could not cancel the dedicated builder request", + "dedicated-builder-your-worker": "Your dedicated worker", + "dedicated-builder-active-desc": "New native builds prefer your dedicated worker. If it is busy or offline, they can use Capgo shared workers when fallback is enabled.", + "dedicated-builder-worker-unknown": "Unknown", + "dedicated-builder-worker-idle": "Idle", + "dedicated-builder-worker-busy": "Busy", + "dedicated-builder-worker-offline": "Offline", + "dedicated-builder-pool-id": "Pool ID", + "dedicated-builder-pool-pending": "Assigned at activation", + "dedicated-builder-active-builds": "Active builds preferring dedicated", + "dedicated-builder-activated-at": "Activated", + "dedicated-builder-fallback-title": "Fall back to shared workers", + "dedicated-builder-fallback-desc": "When your dedicated worker is busy or offline, route builds to Capgo's shared pool instead of waiting.", + "dedicated-builder-fallback-updated": "Fallback setting updated", + "dedicated-builder-fallback-error": "Could not update fallback setting", + "dedicated-builder-how-it-works-title": "How routing works", + "dedicated-builder-how-it-works-1": "New builds prefer your dedicated worker first.", + "dedicated-builder-how-it-works-2": "If that worker is already running a build (or offline), Capgo uses the shared pool when fallback is on.", + "dedicated-builder-how-it-works-3": "Build history shows whether a job used your dedicated pool or the shared pool.", + "dedicated-builder-suspended-title": "Dedicated builder suspended", + "dedicated-builder-suspended-desc": "Your dedicated worker is temporarily unavailable. Contact support if you need it reactivated.", + "dedicated-builder-access-required": "Billing access required", + "dedicated-builder-banner-title": "Need a dedicated native builder?", + "dedicated-builder-banner-desc": "Get your own worker pool for faster, isolated native builds — with shared fallback when busy.", + "dedicated-builder-banner-cta": "Set up dedicated builder", + "dedicated-builder-banner-view": "View dedicated builder", + "dedicated-builder-banner-view-status": "View request status", + "builder-pool": "Preferred pool", + "builder-pool-dedicated": "Dedicated", + "builder-pool-shared": "Shared", + "plan-feature-dedicated-builder": "Dedicated native builder available" } diff --git a/src/components/dashboard/DedicatedBuilderBanner.vue b/src/components/dashboard/DedicatedBuilderBanner.vue new file mode 100644 index 0000000000..a79c1b7947 --- /dev/null +++ b/src/components/dashboard/DedicatedBuilderBanner.vue @@ -0,0 +1,117 @@ + + + diff --git a/src/components/tables/BuildTable.vue b/src/components/tables/BuildTable.vue index 00e2b00a7a..501eccfd46 100644 --- a/src/components/tables/BuildTable.vue +++ b/src/components/tables/BuildTable.vue @@ -3,7 +3,7 @@ import type { Ref } from 'vue' import type { TableColumn } from '../comp_def' import type { Database } from '~/types/supabase.types' import { Capacitor } from '@capacitor/core' -import { computed, onMounted, ref, watch } from 'vue' +import { computed, h, onMounted, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { toast } from 'vue-sonner' import IconEye from '~icons/heroicons/eye' @@ -287,17 +287,56 @@ columns.value = [ key: 'status', mobile: true, class: 'truncate max-w-24', - displayFunction: (elem: Element) => elem.status, + renderFunction: (elem: Element) => h('span', { + class: `font-semibold ${getStatusColor(elem.status)}`, + }, elem.status), + }, + { + label: t('builder-pool'), + key: 'builder_pool', + class: 'truncate max-w-24', + // Preferred pool at request time (may differ from actual when fallback runs). + renderFunction: (elem: Element) => { + if (elem.builder_pool === 'dedicated') { + return h('span', { + class: 'inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-azure-500/10 text-azure-700 dark:text-azure-300', + }, t('builder-pool-dedicated')) + } + if (elem.builder_pool === 'shared') { + return h('span', { + class: 'inline-flex px-2 py-0.5 text-xs font-medium rounded-full bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300', + }, t('builder-pool-shared')) + } + return h('span', { class: 'text-gray-400 dark:text-gray-600' }, '—') + }, }, { label: t('error'), key: 'last_error', mobile: true, class: 'max-w-48', - displayFunction: (elem: Element) => { + renderFunction: (elem: Element) => { if (!elem.last_error) - return '-' - return elem.last_error.length > 50 ? `${elem.last_error.substring(0, 50)}...` : elem.last_error + return h('span', {}, '-') + const truncated = elem.last_error.length > 50 + ? `${elem.last_error.substring(0, 50)}...` + : elem.last_error + return h('div', { class: 'flex items-center gap-2' }, [ + h('span', { + class: 'max-w-xs text-red-600 truncate dark:text-red-400', + }, truncated), + h('button', { + 'type': 'button', + 'aria-label': t('build-error-details'), + 'class': 'p-1 text-gray-500 rounded-md cursor-pointer shrink-0 dark:text-gray-400 hover:text-gray-600 hover:bg-gray-200 dark:hover:bg-gray-700 dark:hover:text-gray-300', + 'onClick': (event: MouseEvent) => { + event.stopPropagation() + showErrorDetails(elem.last_error!) + }, + }, [ + h(IconEye, { class: 'w-4 h-4' }), + ]), + ]) }, }, { @@ -353,40 +392,7 @@ watch(showSetupFlow, (newValue) => { @add="addOne()" @reset="reload()" @reload="getData()" - > - - - - + /> diff --git a/src/constants/organizationTabs.ts b/src/constants/organizationTabs.ts index b0e13c42e7..837e57d0e9 100644 --- a/src/constants/organizationTabs.ts +++ b/src/constants/organizationTabs.ts @@ -5,6 +5,7 @@ import IconPlan from '~icons/heroicons/credit-card' import IconCredits from '~icons/heroicons/currency-dollar' import IconWebhook from '~icons/heroicons/globe-alt' import IconInfo from '~icons/heroicons/information-circle' +import IconServer from '~icons/heroicons/server-stack' import IconSecurity from '~icons/heroicons/shield-check' import IconUsers from '~icons/heroicons/users' @@ -14,6 +15,7 @@ export const organizationTabs: Tab[] = [ { label: 'groups', key: '/settings/organization/groups', icon: IconUsers }, { label: 'plans', key: '/settings/organization/plans', icon: IconPlan }, { label: 'credits', key: '/settings/organization/credits', icon: IconCredits }, + { label: 'dedicated-builder', key: '/settings/organization/dedicated-builder', icon: IconServer }, { label: 'security', key: '/settings/organization/security', icon: IconSecurity }, { label: 'usage', key: '/settings/organization/usage', icon: IconChart }, { label: 'audit-logs', key: '/settings/organization/auditlogs', icon: IconAudit }, diff --git a/src/layouts/settings.vue b/src/layouts/settings.vue index a020cfd2c8..7a7ae75cce 100644 --- a/src/layouts/settings.vue +++ b/src/layouts/settings.vue @@ -167,6 +167,17 @@ watchEffect(() => { if (!needsPlans && hasPlans) organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/plans') + // Dedicated builder - visible to users who can read billing + const needsDedicatedBuilder = canReadBilling.value + const hasDedicatedBuilder = organizationTabs.value.some(tab => tab.key === '/settings/organization/dedicated-builder') + if (needsDedicatedBuilder && !hasDedicatedBuilder) { + const base = baseOrgTabs.find(t => t.key === '/settings/organization/dedicated-builder') + if (base) + organizationTabs.value.push({ ...base }) + } + if (!needsDedicatedBuilder && hasDedicatedBuilder) + organizationTabs.value = organizationTabs.value.filter(tab => tab.key !== '/settings/organization/dedicated-builder') + // Audit logs - visible only to super_admins const needsAuditLogs = canReadAuditLogs.value const hasAuditLogs = organizationTabs.value.find(tab => tab.key === '/settings/organization/audit-logs') diff --git a/src/pages/app/[app].builds.vue b/src/pages/app/[app].builds.vue index f78ba0d8c0..a742570404 100644 --- a/src/pages/app/[app].builds.vue +++ b/src/pages/app/[app].builds.vue @@ -7,6 +7,7 @@ import IconAlertCircle from '~icons/lucide/alert-circle' import BuildChartControls from '~/components/dashboard/BuildChartControls.vue' import BuildStatsCard from '~/components/dashboard/BuildStatsCard.vue' import BuildTimeCard from '~/components/dashboard/BuildTimeCard.vue' +import DedicatedBuilderBanner from '~/components/dashboard/DedicatedBuilderBanner.vue' import { useSupabase } from '~/services/supabase' import { useDisplayStore } from '~/stores/display' @@ -73,6 +74,7 @@ watchEffect(async () => {
+ +import type { DedicatedBuilder } from '~/services/dedicatedBuilder' +import { computedAsync } from '@vueuse/core' +import { storeToRefs } from 'pinia' +import { computed, onMounted, ref, watch } from 'vue' +import { useI18n } from 'vue-i18n' +import { toast } from 'vue-sonner' +import IconCheck from '~icons/heroicons/check-circle' +import IconClock from '~icons/heroicons/clock' +import IconCpu from '~icons/heroicons/cpu-chip' +import IconServer from '~icons/heroicons/server-stack' +import IconSparkles from '~icons/heroicons/sparkles' +import RbacPermissionOnlyModal from '~/components/RbacPermissionOnlyModal.vue' +import Spinner from '~/components/Spinner.vue' +import { formatLocalDateTime } from '~/services/date' +import { + DedicatedBuilderApiError, + fetchDedicatedBuilder, + requestDedicatedBuilder, + updateDedicatedBuilder, +} from '~/services/dedicatedBuilder' +import { checkPermissions } from '~/services/permissions' +import { useDialogV2Store } from '~/stores/dialogv2' +import { useDisplayStore } from '~/stores/display' +import { useOrganizationStore } from '~/stores/organization' + +const { t } = useI18n() +const displayStore = useDisplayStore() +const organizationStore = useOrganizationStore() +const dialogStore = useDialogV2Store() +const { currentOrganization } = storeToRefs(organizationStore) + +displayStore.NavTitle = t('dedicated-builder') + +const isLoading = ref(true) +const isSubmitting = ref(false) +const isSavingFallback = ref(false) +const showAdminModal = ref(false) +const dedicatedBuilder = ref(null) + +const useCase = ref('') +const monthlyBuildsEstimate = ref(null) +const platformIos = ref(true) +const platformAndroid = ref(true) +let loadToken = 0 + +const canReadBilling = computedAsync(async () => { + const orgId = currentOrganization.value?.gid + if (!orgId) + return false + return await checkPermissions('org.read_billing', { orgId }) +}, false) + +const canUpdateBilling = computedAsync(async () => { + const orgId = currentOrganization.value?.gid + if (!orgId) + return false + return await checkPermissions('org.update_billing', { orgId }) +}, false) + +const status = computed(() => dedicatedBuilder.value?.status ?? null) +const isPending = computed(() => status.value === 'requested' || status.value === 'provisioning') +const isActive = computed(() => status.value === 'active') +const showRequestForm = computed(() => !dedicatedBuilder.value || status.value === 'cancelled') + +const workerStatusLabel = computed(() => { + const workerStatus = dedicatedBuilder.value?.worker_status + if (!workerStatus || workerStatus === 'unknown') + return t('dedicated-builder-worker-unknown') + return t(`dedicated-builder-worker-${workerStatus}`) +}) + +const workerStatusClass = computed(() => { + switch (dedicatedBuilder.value?.worker_status) { + case 'idle': + return 'text-green-600 dark:text-green-400 bg-green-50 dark:bg-green-900/30' + case 'busy': + return 'text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-900/30' + case 'offline': + return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/30' + default: + return 'text-slate-600 dark:text-slate-300 bg-slate-100 dark:bg-slate-800' + } +}) + +async function loadDedicatedBuilder() { + const orgId = currentOrganization.value?.gid + const token = ++loadToken + if (!orgId || !canReadBilling.value) { + if (token === loadToken) { + dedicatedBuilder.value = null + isLoading.value = false + } + return + } + + isLoading.value = true + try { + const row = await fetchDedicatedBuilder(orgId) + if (token !== loadToken) + return + dedicatedBuilder.value = row + } + catch (error) { + if (token !== loadToken) + return + console.error('Failed to load dedicated builder', error) + dedicatedBuilder.value = null + toast.error(t('dedicated-builder-load-error')) + } + finally { + if (token === loadToken) + isLoading.value = false + } +} + +onMounted(async () => { + await organizationStore.dedupFetchOrganizations() + await loadDedicatedBuilder() +}) + +watch(currentOrganization, async () => { + await loadDedicatedBuilder() +}) + +watch(canReadBilling, async () => { + // Load on grant and clear/invalidate in-flight work on revoke. + await loadDedicatedBuilder() +}) + +async function submitRequest() { + const orgId = currentOrganization.value?.gid + if (!orgId) + return + + if (!canUpdateBilling.value) { + showAdminModal.value = true + return + } + + if (!platformIos.value && !platformAndroid.value) { + toast.error(t('dedicated-builder-platforms-required')) + return + } + + isSubmitting.value = true + try { + const platforms = [ + ...(platformIos.value ? ['ios'] : []), + ...(platformAndroid.value ? ['android'] : []), + ] + dedicatedBuilder.value = await requestDedicatedBuilder({ + orgId, + useCase: useCase.value.trim() || undefined, + monthlyBuildsEstimate: monthlyBuildsEstimate.value, + platforms, + }) + toast.success(t('dedicated-builder-request-success')) + } + catch (error) { + console.error('Failed to request dedicated builder', error) + const message = error instanceof DedicatedBuilderApiError && error.code === 'dedicated_builder_exists' + ? t('dedicated-builder-already-exists') + : t('dedicated-builder-request-error') + toast.error(message) + } + finally { + isSubmitting.value = false + } +} + +async function toggleFallback(nextValue: boolean) { + const orgId = currentOrganization.value?.gid + if (!orgId || !dedicatedBuilder.value) + return + + if (!canUpdateBilling.value) { + showAdminModal.value = true + return + } + + isSavingFallback.value = true + try { + dedicatedBuilder.value = await updateDedicatedBuilder(orgId, { + allow_shared_fallback: nextValue, + }) + toast.success(t('dedicated-builder-fallback-updated')) + } + catch (error) { + console.error('Failed to update fallback', error) + toast.error(t('dedicated-builder-fallback-error')) + } + finally { + isSavingFallback.value = false + } +} + +function cancelRequest() { + const orgId = currentOrganization.value?.gid + if (!orgId || !dedicatedBuilder.value) + return + + if (!canUpdateBilling.value) { + showAdminModal.value = true + return + } + + dialogStore.openDialog({ + title: t('dedicated-builder-cancel-title'), + description: t('dedicated-builder-cancel-description'), + buttons: [ + { text: t('button-cancel'), role: 'cancel' }, + { + text: t('dedicated-builder-cancel-confirm'), + role: 'danger', + handler: async () => { + try { + dedicatedBuilder.value = await updateDedicatedBuilder(orgId, { cancel: true }) + toast.success(t('dedicated-builder-cancel-success')) + } + catch (error) { + console.error('Failed to cancel dedicated builder', error) + toast.error(t('dedicated-builder-cancel-error')) + } + }, + }, + ], + }) +} + + +