'use client'; import { useEffect, useState } from 'react'; import Link from 'next/link'; import { Alert, App, Button, Modal, Select, Space, Typography } from 'antd'; import { adminApi } from '@/lib/api'; import { idempotencyKey, postPublicOperation } from '@/lib/public-api'; import { errorMessage, shortHash } from '@/lib/template-format'; import { READ_TIMEOUT_MS } from '@/lib/template-reads'; import type { TemplateBuildStart, TemplateBuildStatus } from '@/lib/types'; import type { TemplateActions } from './template-actions'; import type { TemplatesData } from './use-templates-data'; import { templateHref } from './links'; const { Text, Paragraph, Title } = Typography; interface BuildEnvironment { environment: string; architecture: string; runtime: string; runtime_version: string; kernel_version: string; } const pairLabel = (pair: BuildEnvironment) => `${pair.runtime === 'firecracker' ? 'Firecracker' : pair.runtime === 'cloud-hypervisor' ? 'Cloud Hypervisor' : pair.runtime} ${pair.runtime_version} · Linux ${pair.kernel_version} · ${pair.architecture}`; export default function SeedBuilder({ data, actions }: { data: TemplatesData; actions: TemplateActions }) { const { message } = App.useApp(); const [open, setOpen] = useState(false); const [pairs, setPairs] = useState([]); const [pairsLoading, setPairsLoading] = useState(true); const [pairsError, setPairsError] = useState(null); const [environment, setEnvironment] = useState(''); const [attempt, setAttempt] = useState<{ key: string; environment: string } | null>(null); const [job, setJob] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [sandboxId, setSandboxId] = useState(null); const jobId = job?.job_id; const refreshReleases = data.refreshTemplateReleases; const terminal = job && ['succeeded', 'failed', 'publish_failed'].includes(job.state); const release = data.templateReleases.find((entry) => entry.template_name === 'seed' && entry.content_hash === job?.content_hash); const pair = pairs.find((entry) => entry.environment === attempt?.environment); useEffect(() => { let cancelled = false; adminApi.get<{ environments: BuildEnvironment[] }>('/v1/system-template-environments', { signal: AbortSignal.timeout(READ_TIMEOUT_MS) }) .then((result) => { if (!cancelled) setPairs(result.environments); }) .catch((err) => { if (!cancelled) setPairsError(errorMessage(err)); }) .finally(() => { if (!cancelled) setPairsLoading(false); }); return () => { cancelled = true; }; }, []); useEffect(() => { if (!jobId || terminal) return; let cancelled = false; let timer: ReturnType | undefined; const poll = async () => { try { const status = await adminApi.get(`/v1/templates/seed/builds/${encodeURIComponent(jobId)}`, { signal: AbortSignal.timeout(READ_TIMEOUT_MS) }); if (cancelled) return; setJob(status); setError(null); if (['succeeded', 'failed', 'publish_failed'].includes(status.state)) { await refreshReleases({ showLoading: false }); return; } } catch (err) { if (cancelled) return; setError(errorMessage(err)); } if (!cancelled) timer = setTimeout(poll, 3000); }; void poll(); return () => { cancelled = true; if (timer) clearTimeout(timer); }; }, [jobId, terminal, refreshReleases]); const build = async () => { const selected = attempt ?? { key: idempotencyKey(), environment }; setAttempt(selected); setBusy(true); setError(null); try { const started = await adminApi.post('/v1/system-templates/seed/builds', { publish_tags: [], ...(selected.environment ? { environment: selected.environment } : {}), }, { 'Idempotency-Key': selected.key }); setJob({ ...started, state: 'pending', content_hash: null, error: null }); setOpen(false); } catch (err) { setError(`${errorMessage(err)}. Retry uses the same request key; the build may already have started.`); } finally { setBusy(false); } }; const test = async () => { if (!job?.content_hash) return; setBusy(true); try { const accepted = await postPublicOperation('/sandboxes', { name: `seed-check-${job.job_id}`, template: `seed@${job.content_hash}`, runtime: 'fc', env: {}, metadata: {}, resources: null, }); setSandboxId(accepted.sandbox_id); } catch (err) { message.error(`${errorMessage(err)}. Check the sandbox list before retrying.`); } finally { setBusy(false); } }; return ( Seed builder {job ? ( Runtime/kernel pair: {pair ? pairLabel(pair) : attempt?.environment || 'Cluster default (bundled recipe)'} Job: {job.job_id}. The build continues if you leave this page. {job.error ? {job.error} : null} Seed builds {release ? Seed release : null} {job.state === 'succeeded' && !sandboxId ? : null} {sandboxId ? Test sandbox: {sandboxId}. Delete it when you are done. : null} {release && !release.tags.includes('latest') && actions.canManageTemplateReleases ? : null} {release?.tags.includes('latest') ? seed:latest points to this release. : null} {terminal ? : null} )} /> ) : null} {error && !open ? : null} void build()} onCancel={() => setOpen(false)} confirmLoading={busy} closable={!busy} maskClosable={!busy} cancelButtonProps={{ disabled: busy }} okButtonProps={{ disabled: pairsLoading || Boolean(environment && !pairs.some((entry) => entry.environment === environment)) }}> Build a seed from the recipe bundled with this cluster. Nothing else is built or promoted.