deploy
This commit is contained in:
parent
ab0cf8d95c
commit
53629519f5
@ -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 {
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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 ""
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user