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.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 {
|
||||||
|
|||||||
@ -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"`
|
||||||
|
|||||||
@ -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") {
|
||||||
|
|||||||
@ -157,25 +157,71 @@ func (m *Manager) ProcessBuild(ctx context.Context, buildID uint) error {
|
|||||||
if err := m.db.WithContext(ctx).Model(&model.BuildJob{}).
|
if err := m.db.WithContext(ctx).Model(&model.BuildJob{}).
|
||||||
Where("id = ?", buildID).
|
Where("id = ?", buildID).
|
||||||
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 ""
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -1,23 +1,26 @@
|
|||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Button,
|
Box,
|
||||||
Card,
|
Button,
|
||||||
CardContent,
|
Card,
|
||||||
Dialog,
|
CardContent,
|
||||||
DialogActions,
|
Chip,
|
||||||
DialogContent,
|
CircularProgress,
|
||||||
DialogTitle,
|
Dialog,
|
||||||
MenuItem,
|
DialogActions,
|
||||||
Snackbar,
|
DialogContent,
|
||||||
Stack,
|
DialogTitle,
|
||||||
Table,
|
MenuItem,
|
||||||
TableBody,
|
Snackbar,
|
||||||
TableCell,
|
Stack,
|
||||||
TableContainer,
|
Table,
|
||||||
TableHead,
|
TableBody,
|
||||||
TableRow,
|
TableCell,
|
||||||
TextField,
|
TableContainer,
|
||||||
Typography
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
||||||
@ -26,191 +29,353 @@ import StatusChip from '@/components/StatusChip'
|
|||||||
import type { BuildJob, CatalogService } from '@/types'
|
import type { BuildJob, CatalogService } from '@/types'
|
||||||
|
|
||||||
type FormState = {
|
type FormState = {
|
||||||
service_name: string
|
service_name: string
|
||||||
release_id: string
|
release_id: string
|
||||||
branch: string
|
branch: string
|
||||||
operator: string
|
operator: string
|
||||||
}
|
}
|
||||||
|
|
||||||
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 [message, setMessage] = useState('')
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
const [form, setForm] = useState<FormState>({
|
const [selectedBuildID, setSelectedBuildID] = useState<number | null>(null)
|
||||||
service_name: '',
|
const [buildDetail, setBuildDetail] = useState<BuildJob | null>(null)
|
||||||
release_id: '',
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
branch: 'main',
|
const [message, setMessage] = useState('')
|
||||||
operator: defaultOperator
|
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(
|
const upsertBuild = (build: BuildJob) => {
|
||||||
() => services.find((service) => service.name === form.service_name),
|
setBuilds((prev) => {
|
||||||
[form.service_name, services]
|
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 load = async () => {
|
||||||
const first = catalog[0]
|
const [buildRows, catalog] = await Promise.all([api.listBuilds(), api.listServices()])
|
||||||
setForm({
|
setBuilds(buildRows)
|
||||||
service_name: first?.name ?? '',
|
setServices(catalog)
|
||||||
release_id: '',
|
if (!form.service_name && catalog.length > 0) {
|
||||||
branch: first?.default_branch ?? 'main',
|
resetForm(catalog)
|
||||||
operator: defaultOperator
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
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 timer = window.setInterval(() => {
|
||||||
const [buildRows, catalog] = await Promise.all([api.listBuilds(), api.listServices()])
|
void loadBuildDetail(selectedBuildID)
|
||||||
setBuilds(buildRows)
|
}, 2000)
|
||||||
setServices(catalog)
|
|
||||||
if (!form.service_name && catalog.length > 0) {
|
return () => window.clearInterval(timer)
|
||||||
resetForm(catalog)
|
}, [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(() => {
|
const openBuildDetail = (buildID: number) => {
|
||||||
void load()
|
setSelectedBuildID(buildID)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
setBuildDetail(null)
|
||||||
}, [])
|
setDetailOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
const createBuild = async () => {
|
return (
|
||||||
await api.createBuild(form)
|
<Stack spacing={3}>
|
||||||
setOpen(false)
|
<Card>
|
||||||
resetForm(services)
|
<CardContent>
|
||||||
setMessage('构建任务已创建,后台会自动拉取仓库、构建并上传 COS')
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
||||||
await load()
|
<Button
|
||||||
}
|
variant="contained"
|
||||||
|
onClick={() => {
|
||||||
|
resetForm(services)
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
新建构建
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" onClick={() => void load()}>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
return (
|
<Card>
|
||||||
<Stack spacing={3}>
|
<CardContent>
|
||||||
<Card>
|
<Typography className="section-title" sx={{ mb: 2 }}>
|
||||||
<CardContent>
|
构建记录
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.5}>
|
</Typography>
|
||||||
<Button
|
<TableContainer>
|
||||||
variant="contained"
|
<Table>
|
||||||
onClick={() => {
|
<TableHead>
|
||||||
resetForm(services)
|
<TableRow>
|
||||||
setOpen(true)
|
<TableCell>ID</TableCell>
|
||||||
}}
|
<TableCell>服务</TableCell>
|
||||||
>
|
<TableCell>版本</TableCell>
|
||||||
新建构建
|
<TableCell>分支</TableCell>
|
||||||
</Button>
|
<TableCell>状态</TableCell>
|
||||||
<Button variant="outlined" onClick={() => void load()}>
|
<TableCell>提交</TableCell>
|
||||||
刷新
|
<TableCell>构建机</TableCell>
|
||||||
</Button>
|
<TableCell>错误摘要</TableCell>
|
||||||
</Stack>
|
<TableCell align="right">动作</TableCell>
|
||||||
</CardContent>
|
</TableRow>
|
||||||
</Card>
|
</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>
|
<Dialog open={open} onClose={() => setOpen(false)} fullWidth maxWidth="sm">
|
||||||
<CardContent>
|
<DialogTitle>新建构建任务</DialogTitle>
|
||||||
<Typography className="section-title" sx={{ mb: 2 }}>
|
<DialogContent>
|
||||||
构建记录
|
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||||
</Typography>
|
<TextField
|
||||||
<TableContainer>
|
select
|
||||||
<Table>
|
label="微服务"
|
||||||
<TableHead>
|
value={form.service_name}
|
||||||
<TableRow>
|
onChange={(event) => {
|
||||||
<TableCell>ID</TableCell>
|
const serviceName = event.target.value
|
||||||
<TableCell>服务</TableCell>
|
const serviceMeta = services.find((item) => item.name === serviceName)
|
||||||
<TableCell>版本</TableCell>
|
setForm((prev) => ({
|
||||||
<TableCell>分支</TableCell>
|
...prev,
|
||||||
<TableCell>状态</TableCell>
|
service_name: serviceName,
|
||||||
<TableCell>提交</TableCell>
|
branch: serviceMeta?.default_branch ?? prev.branch
|
||||||
<TableCell>构建机</TableCell>
|
}))
|
||||||
<TableCell>COS Key</TableCell>
|
}}
|
||||||
</TableRow>
|
>
|
||||||
</TableHead>
|
{services.map((service) => (
|
||||||
<TableBody>
|
<MenuItem key={service.name} value={service.name}>
|
||||||
{builds.map((row) => (
|
{service.name}
|
||||||
<TableRow key={row.id}>
|
</MenuItem>
|
||||||
<TableCell>{row.id}</TableCell>
|
))}
|
||||||
<TableCell>{row.service_name}</TableCell>
|
</TextField>
|
||||||
<TableCell>{row.release_id}</TableCell>
|
<TextField
|
||||||
<TableCell>{row.branch}</TableCell>
|
label="Release ID"
|
||||||
<TableCell>
|
placeholder="例如 20260407-abc1234"
|
||||||
<StatusChip status={row.status} />
|
value={form.release_id}
|
||||||
</TableCell>
|
onChange={(event) => setForm((prev) => ({ ...prev, release_id: event.target.value }))}
|
||||||
<TableCell>{row.commit_sha || '-'}</TableCell>
|
/>
|
||||||
<TableCell>{row.build_host || '-'}</TableCell>
|
<TextField
|
||||||
<TableCell sx={{ maxWidth: 420, wordBreak: 'break-all' }}>{row.cos_key || '-'}</TableCell>
|
label="分支"
|
||||||
</TableRow>
|
value={form.branch}
|
||||||
))}
|
onChange={(event) => setForm((prev) => ({ ...prev, branch: event.target.value }))}
|
||||||
</TableBody>
|
/>
|
||||||
</Table>
|
<TextField
|
||||||
</TableContainer>
|
label="操作人"
|
||||||
</CardContent>
|
value={form.operator}
|
||||||
</Card>
|
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">
|
<Dialog open={detailOpen} onClose={() => setDetailOpen(false)} fullWidth maxWidth="lg">
|
||||||
<DialogTitle>新建构建任务</DialogTitle>
|
<DialogTitle>构建日志</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent dividers>
|
||||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
{detailLoading && !selectedBuild ? (
|
||||||
<TextField
|
<Stack alignItems="center" justifyContent="center" sx={{ py: 8 }}>
|
||||||
select
|
<CircularProgress size={28} />
|
||||||
label="微服务"
|
</Stack>
|
||||||
value={form.service_name}
|
) : selectedBuild ? (
|
||||||
onChange={(event) => {
|
<Stack spacing={2}>
|
||||||
const serviceName = event.target.value
|
<Stack direction={{ xs: 'column', lg: 'row' }} spacing={1.5} justifyContent="space-between">
|
||||||
const serviceMeta = services.find((item) => item.name === serviceName)
|
<Stack spacing={1}>
|
||||||
setForm((prev) => ({
|
<Typography variant="h6">{`${selectedBuild.service_name} · ${selectedBuild.release_id}`}</Typography>
|
||||||
...prev,
|
<Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
|
||||||
service_name: serviceName,
|
<Chip size="small" variant="outlined" label={`分支 ${selectedBuild.branch || '-'}`} />
|
||||||
branch: serviceMeta?.default_branch ?? prev.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)}`} />
|
||||||
{services.map((service) => (
|
</Stack>
|
||||||
<MenuItem key={service.name} value={service.name}>
|
</Stack>
|
||||||
{service.name}
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
</MenuItem>
|
<StatusChip status={selectedBuild.status} />
|
||||||
))}
|
<Button variant="outlined" onClick={() => selectedBuildID && void loadBuildDetail(selectedBuildID, true)}>
|
||||||
</TextField>
|
立即刷新
|
||||||
<TextField
|
</Button>
|
||||||
label="Release ID"
|
</Stack>
|
||||||
placeholder="例如 20260407-abc1234"
|
</Stack>
|
||||||
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>
|
|
||||||
|
|
||||||
<Snackbar open={Boolean(message)} autoHideDuration={2600} onClose={() => setMessage('')}>
|
{activeBuildStatuses.has(selectedBuild.status) ? (
|
||||||
<Alert severity="success" variant="filled">
|
<Alert severity="info" variant="outlined">
|
||||||
{message}
|
当前任务仍在执行,日志会每 2 秒自动刷新。
|
||||||
</Alert>
|
</Alert>
|
||||||
</Snackbar>
|
) : null}
|
||||||
</Stack>
|
|
||||||
)
|
{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
|
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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user