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.POST("/releases", handler.saveRelease)
api.GET("/builds", handler.listBuilds) api.GET("/builds", handler.listBuilds)
api.GET("/builds/:id", handler.getBuild)
api.POST("/builds", handler.createBuild) api.POST("/builds", handler.createBuild)
api.GET("/release-runs", handler.listReleaseRuns) api.GET("/release-runs", handler.listReleaseRuns)
@ -287,6 +288,15 @@ func (h *routerHandler) listBuilds(c *gin.Context) {
c.JSON(http.StatusOK, data) 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) { func (h *routerHandler) createBuild(c *gin.Context) {
var payload buildPayload var payload buildPayload
if err := c.ShouldBindJSON(&payload); err != nil { if err := c.ShouldBindJSON(&payload); err != nil {

View File

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

View File

@ -1,10 +1,12 @@
package orchestrator package orchestrator
import ( import (
"bufio"
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"os/exec" "os/exec"
"strings" "strings"
@ -25,6 +27,7 @@ type BuildContext struct {
ServiceName string ServiceName string
ReleaseID string ReleaseID string
Branch string Branch string
OnOutput func(string)
} }
type BuildResult struct { type BuildResult struct {
@ -142,11 +145,36 @@ func (e *ScriptBuildExecutor) Build(ctx context.Context, buildCtx BuildContext)
command.Dir = e.workDir command.Dir = e.workDir
} }
var output bytes.Buffer stdout, err := command.StdoutPipe()
command.Stdout = &output if err != nil {
command.Stderr = &output 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() raw := output.String()
for _, line := range strings.Split(raw, "\n") { for _, line := range strings.Split(raw, "\n") {

View File

@ -159,23 +159,69 @@ func (m *Manager) ProcessBuild(ctx context.Context, buildID uint) error {
Updates(map[string]any{ Updates(map[string]any{
"status": model.BuildRunning, "status": model.BuildRunning,
"started_at": &startedAt, "started_at": &startedAt,
"log_output": "",
"log_excerpt": "",
"error_message": "",
}).Error; err != nil { }).Error; err != nil {
return err 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{ result, output, err := m.buildExecutor.Build(ctx, orchestrator.BuildContext{
ServiceName: buildJob.ServiceName, ServiceName: buildJob.ServiceName,
ReleaseID: buildJob.ReleaseID, ReleaseID: buildJob.ReleaseID,
Branch: buildJob.Branch, 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() finishedAt := time.Now()
if err != nil { if err != nil {
return m.db.WithContext(ctx).Model(&model.BuildJob{}). return m.db.WithContext(ctx).Model(&model.BuildJob{}).
Where("id = ?", buildID). Where("id = ?", buildID).
Updates(map[string]any{ Updates(map[string]any{
"status": model.BuildFailed, "status": model.BuildFailed,
"log_excerpt": trimLog(output), "log_output": rawOutput,
"error_message": err.Error(), "log_excerpt": trimLog(rawOutput),
"error_message": summarizeBuildError(err, rawOutput),
"finished_at": &finishedAt, "finished_at": &finishedAt,
}).Error }).Error
} }
@ -193,7 +239,8 @@ func (m *Manager) ProcessBuild(ctx context.Context, buildID uint) error {
"cos_key": result.COSKey, "cos_key": result.COSKey,
"artifact_url": result.ArtifactURL, "artifact_url": result.ArtifactURL,
"sha256": result.SHA256, "sha256": result.SHA256,
"log_excerpt": trimLog(output), "log_output": rawOutput,
"log_excerpt": trimLog(rawOutput),
"finished_at": &finishedAt, "finished_at": &finishedAt,
}).Error; err != nil { }).Error; err != nil {
return err return err
@ -850,6 +897,20 @@ func trimLog(raw string) string {
return raw[len(raw)-maxLen:] 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 { func deploymentLogExcerpt(deployment *model.Deployment) string {
if deployment == nil { if deployment == nil {
return "" return ""

View File

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

View File

@ -1,8 +1,11 @@
import { import {
Alert, Alert,
Box,
Button, Button,
Card, Card,
CardContent, CardContent,
Chip,
CircularProgress,
Dialog, Dialog,
DialogActions, DialogActions,
DialogContent, DialogContent,
@ -33,12 +36,23 @@ type FormState = {
} }
const defaultOperator = 'platform-admin' 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() { export default function BuildsPage() {
const [builds, setBuilds] = useState<BuildJob[]>([]) const [builds, setBuilds] = useState<BuildJob[]>([])
const [services, setServices] = useState<CatalogService[]>([]) const [services, setServices] = useState<CatalogService[]>([])
const [open, setOpen] = useState(false) 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 [message, setMessage] = useState('')
const [error, setError] = useState('')
const [form, setForm] = useState<FormState>({ const [form, setForm] = useState<FormState>({
service_name: '', service_name: '',
release_id: '', release_id: '',
@ -51,6 +65,13 @@ export default function BuildsPage() {
[form.service_name, services] [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 resetForm = (catalog: CatalogService[]) => {
const first = catalog[0] const first = catalog[0]
setForm({ setForm({
@ -61,6 +82,16 @@ export default function BuildsPage() {
}) })
} }
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 load = async () => { const load = async () => {
const [buildRows, catalog] = await Promise.all([api.listBuilds(), api.listServices()]) const [buildRows, catalog] = await Promise.all([api.listBuilds(), api.listServices()])
setBuilds(buildRows) setBuilds(buildRows)
@ -70,17 +101,64 @@ export default function BuildsPage() {
} }
} }
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(() => { useEffect(() => {
void load() void load()
// eslint-disable-next-line react-hooks/exhaustive-deps // 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 timer = window.setInterval(() => {
void loadBuildDetail(selectedBuildID)
}, 2000)
return () => window.clearInterval(timer)
}, [detailOpen, selectedBuildID, selectedBuild?.status])
const createBuild = async () => { const createBuild = async () => {
await api.createBuild(form) try {
const created = await api.createBuild(form)
setOpen(false) setOpen(false)
resetForm(services) resetForm(services)
setMessage('构建任务已创建,后台会自动拉取仓库、构建并上传 COS') setMessage('构建任务已创建,已打开实时构建日志')
setSelectedBuildID(created.id)
setBuildDetail(created)
setDetailOpen(true)
upsertBuild(created)
await load() await load()
} catch (err) {
setError(err instanceof Error ? err.message : '创建构建任务失败')
}
}
const openBuildDetail = (buildID: number) => {
setSelectedBuildID(buildID)
setBuildDetail(null)
setDetailOpen(true)
} }
return ( return (
@ -120,7 +198,8 @@ export default function BuildsPage() {
<TableCell></TableCell> <TableCell></TableCell>
<TableCell></TableCell> <TableCell></TableCell>
<TableCell></TableCell> <TableCell></TableCell>
<TableCell>COS Key</TableCell> <TableCell></TableCell>
<TableCell align="right"></TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
@ -135,7 +214,16 @@ export default function BuildsPage() {
</TableCell> </TableCell>
<TableCell>{row.commit_sha || '-'}</TableCell> <TableCell>{row.commit_sha || '-'}</TableCell>
<TableCell>{row.build_host || '-'}</TableCell> <TableCell>{row.build_host || '-'}</TableCell>
<TableCell sx={{ maxWidth: 420, wordBreak: 'break-all' }}>{row.cos_key || '-'}</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> </TableRow>
))} ))}
</TableBody> </TableBody>
@ -184,11 +272,7 @@ export default function BuildsPage() {
value={form.operator} value={form.operator}
onChange={(event) => setForm((prev) => ({ ...prev, operator: event.target.value }))} onChange={(event) => setForm((prev) => ({ ...prev, operator: event.target.value }))}
/> />
<TextField <TextField label="仓库" value={selectedService?.repo ?? ''} slotProps={{ input: { readOnly: true } }} />
label="仓库"
value={selectedService?.repo ?? ''}
slotProps={{ input: { readOnly: true } }}
/>
<Typography variant="body2" color="text.secondary"> <Typography variant="body2" color="text.secondary">
`git fetch / checkout / pull` `git fetch / checkout / pull`
</Typography> </Typography>
@ -206,11 +290,92 @@ export default function BuildsPage() {
</DialogActions> </DialogActions>
</Dialog> </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>
{activeBuildStatuses.has(selectedBuild.status) ? (
<Alert severity="info" variant="outlined">
2
</Alert>
) : 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('')}> <Snackbar open={Boolean(message)} autoHideDuration={2600} onClose={() => setMessage('')}>
<Alert severity="success" variant="filled"> <Alert severity="success" variant="filled">
{message} {message}
</Alert> </Alert>
</Snackbar> </Snackbar>
<Snackbar open={Boolean(error)} autoHideDuration={4000} onClose={() => setError('')}>
<Alert severity="error" variant="filled">
{error}
</Alert>
</Snackbar>
</Stack> </Stack>
) )
} }

View File

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

View File

@ -47,6 +47,18 @@ def normalize_remote(remote: str) -> str:
return value.strip("/").lower() 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( def run_command(
argv: list[str], argv: list[str],
*, *,
@ -55,19 +67,29 @@ def run_command(
allow_failure: bool = False, allow_failure: bool = False,
) -> subprocess.CompletedProcess[str]: ) -> subprocess.CompletedProcess[str]:
log(f"run: {' '.join(argv)}") log(f"run: {' '.join(argv)}")
proc = subprocess.run( proc = subprocess.Popen(
argv, argv,
cwd=str(cwd) if cwd else None, cwd=str(cwd) if cwd else None,
env=env, env=env,
text=True, text=True,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
bufsize=1,
) )
if proc.stdout:
print(proc.stdout, end="") output_parts: list[str] = []
if proc.returncode != 0 and not allow_failure: assert proc.stdout is not None
raise RuntimeError(f"command failed ({proc.returncode}): {' '.join(argv)}") for line in proc.stdout:
return proc 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: class BuildOperator:
@ -158,6 +180,10 @@ class BuildOperator:
env["CGO_ENABLED"] = "0" env["CGO_ENABLED"] = "0"
env["GOOS"] = "linux" env["GOOS"] = "linux"
env["GOARCH"] = "amd64" 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: if self.git_ssh_command:
env["GIT_SSH_COMMAND"] = self.git_ssh_command env["GIT_SSH_COMMAND"] = self.git_ssh_command
return env return env