This commit is contained in:
ZuoZuo 2026-04-07 12:30:27 +08:00
parent ab0cf8d95c
commit 53629519f5
8 changed files with 496 additions and 203 deletions

View File

@ -111,6 +111,7 @@ func NewRouter(cfg config.Config, manager *service.Manager) *gin.Engine {
api.POST("/releases", handler.saveRelease)
api.GET("/builds", handler.listBuilds)
api.GET("/builds/:id", handler.getBuild)
api.POST("/builds", handler.createBuild)
api.GET("/release-runs", handler.listReleaseRuns)
@ -287,6 +288,15 @@ func (h *routerHandler) listBuilds(c *gin.Context) {
c.JSON(http.StatusOK, data)
}
func (h *routerHandler) getBuild(c *gin.Context) {
data, err := h.manager.GetBuild(c.Request.Context(), uintFromParam(c.Param("id")))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, data)
}
func (h *routerHandler) createBuild(c *gin.Context) {
var payload buildPayload
if err := c.ShouldBindJSON(&payload); err != nil {

View File

@ -80,6 +80,7 @@ type BuildJob struct {
COSKey string `gorm:"size:255" json:"cos_key"`
ArtifactURL string `gorm:"size:255" json:"artifact_url"`
SHA256 string `gorm:"size:128" json:"sha256"`
LogOutput string `gorm:"type:text" json:"log_output"`
LogExcerpt string `gorm:"type:text" json:"log_excerpt"`
ErrorMessage string `gorm:"type:text" json:"error_message"`
StartedAt *time.Time `json:"started_at"`

View File

@ -1,10 +1,12 @@
package orchestrator
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os/exec"
"strings"
@ -25,6 +27,7 @@ type BuildContext struct {
ServiceName string
ReleaseID string
Branch string
OnOutput func(string)
}
type BuildResult struct {
@ -142,11 +145,36 @@ func (e *ScriptBuildExecutor) Build(ctx context.Context, buildCtx BuildContext)
command.Dir = e.workDir
}
var output bytes.Buffer
command.Stdout = &output
command.Stderr = &output
stdout, err := command.StdoutPipe()
if err != nil {
return result, "", err
}
command.Stderr = command.Stdout
err := command.Run()
var output bytes.Buffer
if err := command.Start(); err != nil {
return result, "", err
}
reader := bufio.NewReader(stdout)
for {
chunk, readErr := reader.ReadString('\n')
if chunk != "" {
output.WriteString(chunk)
if buildCtx.OnOutput != nil {
buildCtx.OnOutput(chunk)
}
}
if readErr == nil {
continue
}
if readErr == io.EOF {
break
}
return result, output.String(), readErr
}
err = command.Wait()
raw := output.String()
for _, line := range strings.Split(raw, "\n") {

View File

@ -157,25 +157,71 @@ func (m *Manager) ProcessBuild(ctx context.Context, buildID uint) error {
if err := m.db.WithContext(ctx).Model(&model.BuildJob{}).
Where("id = ?", buildID).
Updates(map[string]any{
"status": model.BuildRunning,
"started_at": &startedAt,
"status": model.BuildRunning,
"started_at": &startedAt,
"log_output": "",
"log_excerpt": "",
"error_message": "",
}).Error; err != nil {
return err
}
var liveOutput strings.Builder
lastPersistedAt := time.Now()
lastPersistedLen := 0
persistLiveOutput := func(force bool) {
raw := liveOutput.String()
if !force {
if len(raw) == lastPersistedLen {
return
}
if time.Since(lastPersistedAt) < time.Second && len(raw)-lastPersistedLen < 1024 {
return
}
}
if err := m.db.Model(&model.BuildJob{}).
Where("id = ?", buildID).
Updates(map[string]any{
"log_output": raw,
"log_excerpt": trimLog(raw),
}).Error; err != nil {
log.Printf("persist build log %d: %v", buildID, err)
return
}
lastPersistedAt = time.Now()
lastPersistedLen = len(raw)
}
result, output, err := m.buildExecutor.Build(ctx, orchestrator.BuildContext{
ServiceName: buildJob.ServiceName,
ReleaseID: buildJob.ReleaseID,
Branch: buildJob.Branch,
OnOutput: func(chunk string) {
if chunk == "" {
return
}
liveOutput.WriteString(chunk)
persistLiveOutput(false)
},
})
rawOutput := output
if rawOutput == "" {
rawOutput = liveOutput.String()
}
if rawOutput != "" && liveOutput.Len() == 0 {
liveOutput.WriteString(rawOutput)
}
persistLiveOutput(true)
finishedAt := time.Now()
if err != nil {
return m.db.WithContext(ctx).Model(&model.BuildJob{}).
Where("id = ?", buildID).
Updates(map[string]any{
"status": model.BuildFailed,
"log_excerpt": trimLog(output),
"error_message": err.Error(),
"log_output": rawOutput,
"log_excerpt": trimLog(rawOutput),
"error_message": summarizeBuildError(err, rawOutput),
"finished_at": &finishedAt,
}).Error
}
@ -193,7 +239,8 @@ func (m *Manager) ProcessBuild(ctx context.Context, buildID uint) error {
"cos_key": result.COSKey,
"artifact_url": result.ArtifactURL,
"sha256": result.SHA256,
"log_excerpt": trimLog(output),
"log_output": rawOutput,
"log_excerpt": trimLog(rawOutput),
"finished_at": &finishedAt,
}).Error; err != nil {
return err
@ -850,6 +897,20 @@ func trimLog(raw string) string {
return raw[len(raw)-maxLen:]
}
func summarizeBuildError(err error, output string) string {
lines := strings.Split(output, "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if line == "" || strings.HasPrefix(line, "BUILD_RESULT_JSON=") {
continue
}
if line != err.Error() {
return line
}
}
return err.Error()
}
func deploymentLogExcerpt(deployment *model.Deployment) string {
if deployment == nil {
return ""

View File

@ -30,6 +30,7 @@ export const api = {
listReleases: async (serviceName?: string) => (await client.get<Release[]>('/releases', { params: { service_name: serviceName } })).data,
saveRelease: async (payload: Partial<Release>) => (await client.post<Release>('/releases', payload)).data,
listBuilds: async () => (await client.get<BuildJob[]>('/builds')).data,
getBuild: async (id: number) => (await client.get<BuildJob>(`/builds/${id}`)).data,
createBuild: async (payload: Record<string, unknown>) => (await client.post<BuildJob>('/builds', payload)).data,
listReleaseRuns: async () => (await client.get<ReleaseRun[]>('/release-runs')).data,
getReleaseRun: async (id: number) => (await client.get<ReleaseRun>(`/release-runs/${id}`)).data,

View File

@ -1,23 +1,26 @@
import {
Alert,
Button,
Card,
CardContent,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
MenuItem,
Snackbar,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography
Alert,
Box,
Button,
Card,
CardContent,
Chip,
CircularProgress,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
MenuItem,
Snackbar,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Typography
} from '@mui/material'
import { useEffect, useMemo, useState } from 'react'
@ -26,191 +29,353 @@ import StatusChip from '@/components/StatusChip'
import type { BuildJob, CatalogService } from '@/types'
type FormState = {
service_name: string
release_id: string
branch: string
operator: string
service_name: string
release_id: string
branch: string
operator: string
}
const defaultOperator = 'platform-admin'
const activeBuildStatuses = new Set(['queued', 'running'])
function formatDateTime(value?: string) {
if (!value) return '-'
return new Date(value).toLocaleString()
}
export default function BuildsPage() {
const [builds, setBuilds] = useState<BuildJob[]>([])
const [services, setServices] = useState<CatalogService[]>([])
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [form, setForm] = useState<FormState>({
service_name: '',
release_id: '',
branch: 'main',
operator: defaultOperator
const [builds, setBuilds] = useState<BuildJob[]>([])
const [services, setServices] = useState<CatalogService[]>([])
const [open, setOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(false)
const [selectedBuildID, setSelectedBuildID] = useState<number | null>(null)
const [buildDetail, setBuildDetail] = useState<BuildJob | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [message, setMessage] = useState('')
const [error, setError] = useState('')
const [form, setForm] = useState<FormState>({
service_name: '',
release_id: '',
branch: 'main',
operator: defaultOperator
})
const selectedService = useMemo(
() => services.find((service) => service.name === form.service_name),
[form.service_name, services]
)
const selectedBuild = useMemo(() => {
if (buildDetail && buildDetail.id === selectedBuildID) {
return buildDetail
}
return builds.find((build) => build.id === selectedBuildID) ?? null
}, [buildDetail, builds, selectedBuildID])
const resetForm = (catalog: CatalogService[]) => {
const first = catalog[0]
setForm({
service_name: first?.name ?? '',
release_id: '',
branch: first?.default_branch ?? 'main',
operator: defaultOperator
})
}
const selectedService = useMemo(
() => services.find((service) => service.name === form.service_name),
[form.service_name, services]
)
const upsertBuild = (build: BuildJob) => {
setBuilds((prev) => {
const exists = prev.some((item) => item.id === build.id)
if (!exists) {
return [build, ...prev]
}
return prev.map((item) => (item.id === build.id ? build : item))
})
}
const resetForm = (catalog: CatalogService[]) => {
const first = catalog[0]
setForm({
service_name: first?.name ?? '',
release_id: '',
branch: first?.default_branch ?? 'main',
operator: defaultOperator
})
const load = async () => {
const [buildRows, catalog] = await Promise.all([api.listBuilds(), api.listServices()])
setBuilds(buildRows)
setServices(catalog)
if (!form.service_name && catalog.length > 0) {
resetForm(catalog)
}
}
const loadBuildDetail = async (buildID: number, showLoader = false) => {
if (showLoader) {
setDetailLoading(true)
}
try {
const build = await api.getBuild(buildID)
setBuildDetail(build)
upsertBuild(build)
} catch (err) {
setError(err instanceof Error ? err.message : '加载构建日志失败')
} finally {
if (showLoader) {
setDetailLoading(false)
}
}
}
useEffect(() => {
void load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!detailOpen || !selectedBuildID) return
void loadBuildDetail(selectedBuildID, true)
if (!selectedBuild || !activeBuildStatuses.has(selectedBuild.status)) {
return
}
const load = async () => {
const [buildRows, catalog] = await Promise.all([api.listBuilds(), api.listServices()])
setBuilds(buildRows)
setServices(catalog)
if (!form.service_name && catalog.length > 0) {
resetForm(catalog)
}
const timer = window.setInterval(() => {
void loadBuildDetail(selectedBuildID)
}, 2000)
return () => window.clearInterval(timer)
}, [detailOpen, selectedBuildID, selectedBuild?.status])
const createBuild = async () => {
try {
const created = await api.createBuild(form)
setOpen(false)
resetForm(services)
setMessage('构建任务已创建,已打开实时构建日志')
setSelectedBuildID(created.id)
setBuildDetail(created)
setDetailOpen(true)
upsertBuild(created)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : '创建构建任务失败')
}
}
useEffect(() => {
void load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const openBuildDetail = (buildID: number) => {
setSelectedBuildID(buildID)
setBuildDetail(null)
setDetailOpen(true)
}
const createBuild = async () => {
await api.createBuild(form)
setOpen(false)
resetForm(services)
setMessage('构建任务已创建,后台会自动拉取仓库、构建并上传 COS')
await load()
}
return (
<Stack spacing={3}>
<Card>
<CardContent>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
<Button
variant="contained"
onClick={() => {
resetForm(services)
setOpen(true)
}}
>
</Button>
<Button variant="outlined" onClick={() => void load()}>
</Button>
</Stack>
</CardContent>
</Card>
return (
<Stack spacing={3}>
<Card>
<CardContent>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
<Button
variant="contained"
onClick={() => {
resetForm(services)
setOpen(true)
}}
>
</Button>
<Button variant="outlined" onClick={() => void load()}>
</Button>
</Stack>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography className="section-title" sx={{ mb: 2 }}>
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell align="right"></TableCell>
</TableRow>
</TableHead>
<TableBody>
{builds.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.id}</TableCell>
<TableCell>{row.service_name}</TableCell>
<TableCell>{row.release_id}</TableCell>
<TableCell>{row.branch}</TableCell>
<TableCell>
<StatusChip status={row.status} />
</TableCell>
<TableCell>{row.commit_sha || '-'}</TableCell>
<TableCell>{row.build_host || '-'}</TableCell>
<TableCell sx={{ maxWidth: 360 }}>
<Typography variant="body2" color="text.secondary" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{row.error_message || '-'}
</Typography>
</TableCell>
<TableCell align="right">
<Button size="small" onClick={() => openBuildDetail(row.id)}>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
<Card>
<CardContent>
<Typography className="section-title" sx={{ mb: 2 }}>
</Typography>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell>COS Key</TableCell>
</TableRow>
</TableHead>
<TableBody>
{builds.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.id}</TableCell>
<TableCell>{row.service_name}</TableCell>
<TableCell>{row.release_id}</TableCell>
<TableCell>{row.branch}</TableCell>
<TableCell>
<StatusChip status={row.status} />
</TableCell>
<TableCell>{row.commit_sha || '-'}</TableCell>
<TableCell>{row.build_host || '-'}</TableCell>
<TableCell sx={{ maxWidth: 420, wordBreak: 'break-all' }}>{row.cos_key || '-'}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</CardContent>
</Card>
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm">
<DialogTitle></DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
select
label="微服务"
value={form.service_name}
onChange={(event) => {
const serviceName = event.target.value
const serviceMeta = services.find((item) => item.name === serviceName)
setForm((prev) => ({
...prev,
service_name: serviceName,
branch: serviceMeta?.default_branch ?? prev.branch
}))
}}
>
{services.map((service) => (
<MenuItem key={service.name} value={service.name}>
{service.name}
</MenuItem>
))}
</TextField>
<TextField
label="Release ID"
placeholder="例如 20260407-abc1234"
value={form.release_id}
onChange={(event) => setForm((prev) => ({ ...prev, release_id: event.target.value }))}
/>
<TextField
label="分支"
value={form.branch}
onChange={(event) => setForm((prev) => ({ ...prev, branch: event.target.value }))}
/>
<TextField
label="操作人"
value={form.operator}
onChange={(event) => setForm((prev) => ({ ...prev, operator: event.target.value }))}
/>
<TextField label="仓库" value={selectedService?.repo ?? ''} slotProps={{ input: { readOnly: true } }} />
<Typography variant="body2" color="text.secondary">
`git fetch / checkout / pull`
</Typography>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}></Button>
<Button
variant="contained"
disabled={!form.service_name || !form.release_id || !form.branch}
onClick={() => void createBuild()}
>
</Button>
</DialogActions>
</Dialog>
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm">
<DialogTitle></DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
select
label="微服务"
value={form.service_name}
onChange={(event) => {
const serviceName = event.target.value
const serviceMeta = services.find((item) => item.name === serviceName)
setForm((prev) => ({
...prev,
service_name: serviceName,
branch: serviceMeta?.default_branch ?? prev.branch
}))
}}
>
{services.map((service) => (
<MenuItem key={service.name} value={service.name}>
{service.name}
</MenuItem>
))}
</TextField>
<TextField
label="Release ID"
placeholder="例如 20260407-abc1234"
value={form.release_id}
onChange={(event) => setForm((prev) => ({ ...prev, release_id: event.target.value }))}
/>
<TextField
label="分支"
value={form.branch}
onChange={(event) => setForm((prev) => ({ ...prev, branch: event.target.value }))}
/>
<TextField
label="操作人"
value={form.operator}
onChange={(event) => setForm((prev) => ({ ...prev, operator: event.target.value }))}
/>
<TextField
label="仓库"
value={selectedService?.repo ?? ''}
slotProps={{ input: { readOnly: true } }}
/>
<Typography variant="body2" color="text.secondary">
`git fetch / checkout / pull`
</Typography>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpen(false)}></Button>
<Button
variant="contained"
disabled={!form.service_name || !form.release_id || !form.branch}
onClick={() => void createBuild()}
>
</Button>
</DialogActions>
</Dialog>
<Dialog open={detailOpen} onClose={() => setDetailOpen(false)} fullWidth maxWidth="lg">
<DialogTitle></DialogTitle>
<DialogContent dividers>
{detailLoading && !selectedBuild ? (
<Stack alignItems="center" justifyContent="center" sx={{ py: 8 }}>
<CircularProgress size={28} />
</Stack>
) : selectedBuild ? (
<Stack spacing={2}>
<Stack direction={{ xs: 'column', lg: 'row' }} spacing={1.5} justifyContent="space-between">
<Stack spacing={1}>
<Typography variant="h6">{`${selectedBuild.service_name} · ${selectedBuild.release_id}`}</Typography>
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
<Chip size="small" variant="outlined" label={`分支 ${selectedBuild.branch || '-'}`} />
<Chip size="small" variant="outlined" label={`提交 ${selectedBuild.commit_sha || '-'}`} />
<Chip size="small" variant="outlined" label={`构建机 ${selectedBuild.build_host || '-'}`} />
<Chip size="small" variant="outlined" label={`开始 ${formatDateTime(selectedBuild.started_at || selectedBuild.created_at)}`} />
<Chip size="small" variant="outlined" label={`结束 ${formatDateTime(selectedBuild.finished_at)}`} />
</Stack>
</Stack>
<Stack direction="row" spacing={1} alignItems="center">
<StatusChip status={selectedBuild.status} />
<Button variant="outlined" onClick={() => selectedBuildID && void loadBuildDetail(selectedBuildID, true)}>
</Button>
</Stack>
</Stack>
<Snackbar open={Boolean(message)} autoHideDuration={2600} onClose={() => setMessage('')}>
<Alert severity="success" variant="filled">
{message}
{activeBuildStatuses.has(selectedBuild.status) ? (
<Alert severity="info" variant="outlined">
2
</Alert>
</Snackbar>
</Stack>
)
) : null}
{selectedBuild.error_message ? (
<Alert severity="error" variant="filled">
{selectedBuild.error_message}
</Alert>
) : null}
<Box
component="pre"
sx={{
m: 0,
p: 2,
maxHeight: 560,
overflow: 'auto',
borderRadius: 3,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontSize: 13,
lineHeight: 1.7,
fontFamily: '"SFMono-Regular", "Roboto Mono", "Menlo", monospace',
backgroundColor: 'rgba(16,24,40,0.98)',
color: 'rgb(231, 239, 255)'
}}
>
{selectedBuild.log_output || selectedBuild.log_excerpt || '暂无日志输出'}
</Box>
<Typography variant="body2" color="text.secondary" sx={{ wordBreak: 'break-all' }}>
COS Key: {selectedBuild.cos_key || '-'}
</Typography>
</Stack>
) : (
<Alert severity="warning" variant="outlined">
</Alert>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDetailOpen(false)}></Button>
</DialogActions>
</Dialog>
<Snackbar open={Boolean(message)} autoHideDuration={2600} onClose={() => setMessage('')}>
<Alert severity="success" variant="filled">
{message}
</Alert>
</Snackbar>
<Snackbar open={Boolean(error)} autoHideDuration={4000} onClose={() => setError('')}>
<Alert severity="error" variant="filled">
{error}
</Alert>
</Snackbar>
</Stack>
)
}

View File

@ -51,6 +51,7 @@ export interface BuildJob {
cos_key: string
artifact_url: string
sha256: string
log_output: string
log_excerpt: string
error_message: string
created_at?: string

View File

@ -47,6 +47,18 @@ def normalize_remote(remote: str) -> str:
return value.strip("/").lower()
def git_host(remote: str) -> str:
value = remote.strip()
if not value:
return ""
if value.startswith("git@") and ":" in value:
return value.split("@", 1)[1].split(":", 1)[0].strip().lower()
parsed = urlparse(value)
if parsed.netloc:
return parsed.netloc.strip().lower()
return ""
def run_command(
argv: list[str],
*,
@ -55,19 +67,29 @@ def run_command(
allow_failure: bool = False,
) -> subprocess.CompletedProcess[str]:
log(f"run: {' '.join(argv)}")
proc = subprocess.run(
proc = subprocess.Popen(
argv,
cwd=str(cwd) if cwd else None,
env=env,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
bufsize=1,
)
if proc.stdout:
print(proc.stdout, end="")
if proc.returncode != 0 and not allow_failure:
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(argv)}")
return proc
output_parts: list[str] = []
assert proc.stdout is not None
for line in proc.stdout:
output_parts.append(line)
print(line, end="", flush=True)
returncode = proc.wait()
stdout = "".join(output_parts)
completed = subprocess.CompletedProcess(argv, returncode, stdout)
if returncode != 0 and not allow_failure:
raise RuntimeError(f"command failed ({returncode}): {' '.join(argv)}")
return completed
class BuildOperator:
@ -158,6 +180,10 @@ class BuildOperator:
env["CGO_ENABLED"] = "0"
env["GOOS"] = "linux"
env["GOARCH"] = "amd64"
private_host = git_host(self.repo_url) or git_host(str(self.global_build_cfg.get("gitea_clone_prefix", "")))
if private_host:
env.setdefault("GOPRIVATE", private_host)
env.setdefault("GONOSUMDB", private_host)
if self.git_ssh_command:
env["GIT_SSH_COMMAND"] = self.git_ssh_command
return env