12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# Binary
|
||||
ci-server
|
||||
ci-server.exe
|
||||
|
||||
# Data directory (runtime)
|
||||
data/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
30
Makefile
Normal file
30
Makefile
Normal file
@@ -0,0 +1,30 @@
|
||||
.PHONY: all build-backend build-frontend clean dev run
|
||||
|
||||
# Default target: build everything
|
||||
all: build-frontend build-backend
|
||||
|
||||
# Build the Vue 3 frontend
|
||||
build-frontend:
|
||||
cd web && npm install && npm run build
|
||||
|
||||
# Build the Go backend (with embedded frontend)
|
||||
build-backend:
|
||||
go build -o ci-server .
|
||||
|
||||
# Build for Linux (cross-compile)
|
||||
build-linux:
|
||||
GOOS=linux GOARCH=amd64 go build -o ci-server .
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
rm -rf ci-server ci-server.exe web/dist data
|
||||
|
||||
# Run in development mode (frontend proxied via Vite)
|
||||
dev:
|
||||
@echo "Start the Go server in one terminal: go run ."
|
||||
@echo "Start the Vite dev server in another: cd web && npm run dev"
|
||||
@echo "Then open http://localhost:5173"
|
||||
|
||||
# Run the production server (requires frontend built)
|
||||
run: build-frontend
|
||||
go run .
|
||||
BIN
ci-server.exe~
Normal file
BIN
ci-server.exe~
Normal file
Binary file not shown.
9
ci.iml
Normal file
9
ci.iml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
68
go.mod
Normal file
68
go.mod
Normal file
@@ -0,0 +1,68 @@
|
||||
module ci
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-git/go-git/v5 v5.19.1
|
||||
golang.org/x/crypto v0.50.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.9.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
197
go.sum
Normal file
197
go.sum
Normal file
@@ -0,0 +1,197 @@
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
|
||||
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA=
|
||||
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
|
||||
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
|
||||
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
|
||||
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
|
||||
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
178
internal/builder/builder.go
Normal file
178
internal/builder/builder.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package builder
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ci/internal/model"
|
||||
)
|
||||
|
||||
// LineCallback is called for each line of build output.
|
||||
type LineCallback func(line string)
|
||||
|
||||
// BuildResult holds the outcome of a build.
|
||||
type BuildResult struct {
|
||||
Success bool
|
||||
LogPath string
|
||||
CommitHash string
|
||||
Output string
|
||||
}
|
||||
|
||||
// Service handles build operations.
|
||||
type Service struct{}
|
||||
|
||||
// New creates a new builder Service.
|
||||
func New() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// Execute runs a build for the given project, streaming output via callback.
|
||||
func (s *Service) Execute(ctx context.Context, project *model.Project, workDir, logsDir, commitHash string, onLine LineCallback) (*BuildResult, error) {
|
||||
// Create log file
|
||||
logFileName := fmt.Sprintf("build-%d.log", time.Now().UnixMilli())
|
||||
logPath := filepath.Join(logsDir, logFileName)
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
writer := io.MultiWriter(logFile, &callbackWriter{fn: onLine})
|
||||
|
||||
// Resolve the build script
|
||||
script := strings.TrimSpace(project.BuildScript)
|
||||
if script == "" {
|
||||
// Default: try go build
|
||||
script = "go build -o app ."
|
||||
}
|
||||
|
||||
// Determine shell for the build script
|
||||
var cmd *exec.Cmd
|
||||
if isShellScript(script) {
|
||||
// Multi-line script or complex — write to temp file and execute
|
||||
scriptPath := filepath.Join(workDir, ".build-script.sh")
|
||||
if err := os.WriteFile(scriptPath, []byte("#!/bin/sh\nset -e\n"+script), 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to write build script: %w", err)
|
||||
}
|
||||
cmd = exec.CommandContext(ctx, "sh", scriptPath)
|
||||
} else {
|
||||
cmd = exec.CommandContext(ctx, "sh", "-c", script)
|
||||
}
|
||||
|
||||
cmd.Dir = workDir
|
||||
cmd.Env = os.Environ()
|
||||
cmd.Stdout = writer
|
||||
cmd.Stderr = writer
|
||||
|
||||
fmt.Fprintf(writer, "=== Build started at %s ===\n", time.Now().Format(time.RFC3339))
|
||||
fmt.Fprintf(writer, "Working directory: %s\n", workDir)
|
||||
fmt.Fprintf(writer, "Build script: %s\n", script)
|
||||
fmt.Fprintf(writer, "Commit: %s\n\n", commitHash)
|
||||
|
||||
startTime := time.Now()
|
||||
err = cmd.Run()
|
||||
duration := time.Since(startTime)
|
||||
|
||||
result := &BuildResult{
|
||||
LogPath: logPath,
|
||||
CommitHash: commitHash,
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(writer, "\n=== Build FAILED after %s ===\n", duration.Round(time.Second))
|
||||
fmt.Fprintf(writer, "Error: %v\n", err)
|
||||
result.Success = false
|
||||
result.Output = fmt.Sprintf("Build failed: %v", err)
|
||||
} else {
|
||||
fmt.Fprintf(writer, "\n=== Build SUCCESS after %s ===\n", duration.Round(time.Second))
|
||||
result.Success = true
|
||||
result.Output = fmt.Sprintf("Build succeeded in %s", duration.Round(time.Second))
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ReadLog reads a build log file and returns its contents.
|
||||
func (s *Service) ReadLog(logPath string) (string, error) {
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// ReadLogTail reads the last n lines from a log file.
|
||||
func (s *Service) ReadLogTail(logPath string, lines int) ([]string, error) {
|
||||
file, err := os.Open(logPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var result []string
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
result = append(result, scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(result) > lines {
|
||||
result = result[len(result)-lines:]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isShellScript checks if the script looks multi-line or complex enough to need a temp file.
|
||||
func isShellScript(script string) bool {
|
||||
return strings.Contains(script, "\n") || len(script) > 200
|
||||
}
|
||||
|
||||
// callbackWriter implements io.Writer, calling a function for each line written.
|
||||
type callbackWriter struct {
|
||||
fn LineCallback
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (w *callbackWriter) Write(p []byte) (n int, err error) {
|
||||
if w.fn != nil {
|
||||
w.buf = append(w.buf, p...)
|
||||
for {
|
||||
idx := indexOfByte(w.buf, '\n')
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
line := string(w.buf[:idx])
|
||||
if strings.HasSuffix(line, "\r") {
|
||||
line = line[:len(line)-1]
|
||||
}
|
||||
w.fn(line)
|
||||
w.buf = w.buf[idx+1:]
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (w *callbackWriter) flush() {
|
||||
if w.fn != nil && len(w.buf) > 0 {
|
||||
w.fn(string(w.buf))
|
||||
w.buf = nil
|
||||
}
|
||||
}
|
||||
|
||||
func indexOfByte(data []byte, b byte) int {
|
||||
for i, c := range data {
|
||||
if c == b {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
305
internal/deployer/deployer.go
Normal file
305
internal/deployer/deployer.go
Normal file
@@ -0,0 +1,305 @@
|
||||
package deployer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// LineCallback is called for each line of deploy output.
|
||||
type LineCallback func(line string)
|
||||
|
||||
// FileMapping maps a local file path to a remote server path.
|
||||
type FileMapping struct {
|
||||
Local string `json:"local"`
|
||||
Remote string `json:"remote"`
|
||||
}
|
||||
|
||||
// Config holds SSH connection parameters.
|
||||
type Config struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
AuthMethod string // "key" or "password"
|
||||
SSHKey string // private key content (PEM)
|
||||
Password string
|
||||
}
|
||||
|
||||
// DeployResult holds the outcome of a deployment.
|
||||
type DeployResult struct {
|
||||
Success bool
|
||||
Output string
|
||||
}
|
||||
|
||||
// Service handles SSH deployment operations.
|
||||
type Service struct{}
|
||||
|
||||
// New creates a new deployer Service.
|
||||
func New() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// Deploy performs a full deployment: connect, pre-commands, upload files, post-commands, health check.
|
||||
// Output is streamed via the onLine callback.
|
||||
func (s *Service) Deploy(ctx context.Context, cfg *Config, deployDir string,
|
||||
mappings []FileMapping, preCmd, postCmd, healthCheckURL string,
|
||||
healthCheckTimeout int, onLine LineCallback) (*DeployResult, error) {
|
||||
|
||||
log := func(format string, args ...any) {
|
||||
onLine(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
log("=== Deploy started at %s ===", time.Now().Format(time.RFC3339))
|
||||
log("Target: %s@%s:%d", cfg.Username, cfg.Host, cfg.Port)
|
||||
log("Deploy directory: %s", deployDir)
|
||||
log("Auth method: %s", cfg.AuthMethod)
|
||||
|
||||
// 1. Connect to SSH server
|
||||
client, err := s.dial(cfg)
|
||||
if err != nil {
|
||||
log("ERROR: SSH connection failed: %v", err)
|
||||
return &DeployResult{Success: false, Output: fmt.Sprintf("SSH connection failed: %v", err)}, err
|
||||
}
|
||||
defer client.Close()
|
||||
log("SSH connection established")
|
||||
|
||||
// 2. Ensure remote deploy directory exists
|
||||
if err := s.runCommand(client, fmt.Sprintf("mkdir -p %s", deployDir), onLine); err != nil {
|
||||
log("ERROR: Failed to create deploy directory: %v", err)
|
||||
return &DeployResult{Success: false, Output: fmt.Sprintf("mkdir failed: %v", err)}, err
|
||||
}
|
||||
log("Remote directory ensured: %s", deployDir)
|
||||
|
||||
// 3. Execute pre-deploy commands
|
||||
if preCmd != "" {
|
||||
log("=== Executing pre-deploy command ===")
|
||||
log("$ %s", preCmd)
|
||||
if err := s.runCommand(client, preCmd, onLine); err != nil {
|
||||
log("ERROR: Pre-deploy command failed: %v", err)
|
||||
return &DeployResult{Success: false, Output: fmt.Sprintf("pre-deploy failed: %v", err)}, err
|
||||
}
|
||||
log("Pre-deploy command completed")
|
||||
}
|
||||
|
||||
// 4. Upload files
|
||||
if len(mappings) > 0 {
|
||||
log("=== Uploading %d file(s) ===", len(mappings))
|
||||
}
|
||||
for _, m := range mappings {
|
||||
if err := s.uploadFile(client, m.Local, m.Remote, onLine); err != nil {
|
||||
log("ERROR: Failed to upload %s: %v", m.Local, err)
|
||||
return &DeployResult{Success: false, Output: fmt.Sprintf("upload %s failed: %v", m.Local, err)}, err
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Execute post-deploy commands
|
||||
if postCmd != "" {
|
||||
log("=== Executing post-deploy command ===")
|
||||
log("$ %s", postCmd)
|
||||
if err := s.runCommand(client, postCmd, onLine); err != nil {
|
||||
log("ERROR: Post-deploy command failed: %v", err)
|
||||
return &DeployResult{Success: false, Output: fmt.Sprintf("post-deploy failed: %v", err)}, err
|
||||
}
|
||||
log("Post-deploy command completed")
|
||||
}
|
||||
|
||||
// 6. Health check
|
||||
if healthCheckURL != "" {
|
||||
log("=== Health check ===")
|
||||
log("Checking %s (timeout: %ds)", healthCheckURL, healthCheckTimeout)
|
||||
|
||||
timeout := time.Duration(healthCheckTimeout) * time.Second
|
||||
if err := s.checkHealth(healthCheckURL, timeout); err != nil {
|
||||
log("WARNING: Health check failed: %v", err)
|
||||
// Health check failure is a warning, not a deploy failure
|
||||
} else {
|
||||
log("Health check passed")
|
||||
}
|
||||
}
|
||||
|
||||
log("=== Deploy completed successfully at %s ===", time.Now().Format(time.RFC3339))
|
||||
return &DeployResult{Success: true, Output: "Deployment succeeded"}, nil
|
||||
}
|
||||
|
||||
// dial establishes an SSH connection.
|
||||
func (s *Service) dial(cfg *Config) (*ssh.Client, error) {
|
||||
auth, err := s.makeAuth(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("make auth: %w", err)
|
||||
}
|
||||
|
||||
sshCfg := &ssh.ClientConfig{
|
||||
User: cfg.Username,
|
||||
Auth: []ssh.AuthMethod{auth},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port))
|
||||
return ssh.Dial("tcp", addr, sshCfg)
|
||||
}
|
||||
|
||||
// makeAuth builds the ssh.AuthMethod from config.
|
||||
func (s *Service) makeAuth(cfg *Config) (ssh.AuthMethod, error) {
|
||||
switch cfg.AuthMethod {
|
||||
case "password":
|
||||
return ssh.Password(cfg.Password), nil
|
||||
case "key":
|
||||
// Try to parse the private key
|
||||
key, err := ssh.ParsePrivateKey([]byte(cfg.SSHKey))
|
||||
if err != nil {
|
||||
// Try parsing with passphrase (empty passphrase)
|
||||
key, err2 := ssh.ParsePrivateKeyWithPassphrase([]byte(cfg.SSHKey), []byte{})
|
||||
if err2 != nil {
|
||||
return nil, fmt.Errorf("parse private key: %w (original: %v)", err2, err)
|
||||
}
|
||||
return ssh.PublicKeys(key), nil
|
||||
}
|
||||
return ssh.PublicKeys(key), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported auth method: %s", cfg.AuthMethod)
|
||||
}
|
||||
}
|
||||
|
||||
// runCommand executes a command on the remote host and streams output line by line.
|
||||
func (s *Service) runCommand(client *ssh.Client, cmd string, onLine LineCallback) error {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("new session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// Combine stdout and stderr
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stdout pipe: %w", err)
|
||||
}
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
if err := session.Start(cmd); err != nil {
|
||||
return fmt.Errorf("start command: %w", err)
|
||||
}
|
||||
|
||||
// Stream stdout lines
|
||||
go streamLines(stdout, onLine)
|
||||
// Stream stderr lines
|
||||
go streamLines(stderr, onLine)
|
||||
|
||||
err = session.Wait()
|
||||
if err != nil {
|
||||
if exitErr, ok := err.(*ssh.ExitError); ok {
|
||||
return fmt.Errorf("command exited with %d", exitErr.ExitStatus())
|
||||
}
|
||||
return fmt.Errorf("command error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploadFile copies a local file to a remote path via SSH session.
|
||||
// Uses "cat > remotePath" which is universally available on Linux.
|
||||
func (s *Service) uploadFile(client *ssh.Client, localPath, remotePath string, onLine LineCallback) error {
|
||||
// Get file info
|
||||
info, err := os.Stat(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat local file: %w", err)
|
||||
}
|
||||
|
||||
fileSize := info.Size()
|
||||
onLine(fmt.Sprintf("Uploading %s (%d bytes) -> %s", localPath, fileSize, remotePath))
|
||||
|
||||
// Open local file
|
||||
f, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open local file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return fmt.Errorf("new session: %w", err)
|
||||
}
|
||||
defer session.Close()
|
||||
|
||||
// Pipe file content to cat > remotePath on the server
|
||||
session.Stdin = f
|
||||
|
||||
// Capture stderr for error reporting
|
||||
stderr, err := session.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stderr pipe: %w", err)
|
||||
}
|
||||
|
||||
// Also create parent directories if needed
|
||||
cmd := fmt.Sprintf("mkdir -p $(dirname %s) && cat > %s", remotePath, remotePath)
|
||||
if err := session.Start(cmd); err != nil {
|
||||
return fmt.Errorf("start upload: %w", err)
|
||||
}
|
||||
|
||||
// Read stderr in background
|
||||
errBuf := make([]byte, 4096)
|
||||
n, _ := stderr.Read(errBuf)
|
||||
|
||||
err = session.Wait()
|
||||
if err != nil {
|
||||
if n > 0 {
|
||||
return fmt.Errorf("upload failed: %s: %w", string(errBuf[:n]), err)
|
||||
}
|
||||
return fmt.Errorf("upload failed: %w", err)
|
||||
}
|
||||
|
||||
onLine(fmt.Sprintf("Uploaded %s successfully", localPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkHealth performs an HTTP GET to the health check URL.
|
||||
func (s *Service) checkHealth(url string, timeout time.Duration) error {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
}
|
||||
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("health check request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("health check returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// streamLines reads lines from a reader and calls onLine for each.
|
||||
func streamLines(r io.Reader, onLine LineCallback) {
|
||||
buf := make([]byte, 4096)
|
||||
line := make([]byte, 0)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
if n > 0 {
|
||||
for _, b := range buf[:n] {
|
||||
if b == '\n' {
|
||||
onLine(string(line))
|
||||
line = line[:0]
|
||||
} else if b != '\r' {
|
||||
line = append(line, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if len(line) > 0 {
|
||||
onLine(string(line))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
100
internal/git/git.go
Normal file
100
internal/git/git.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
gogit "github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
)
|
||||
|
||||
// Service handles Git operations for projects.
|
||||
type Service struct{}
|
||||
|
||||
// New creates a new Git Service.
|
||||
func New() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// CloneOrPull clones a repo if it doesn't exist locally, or pulls latest changes.
|
||||
// Returns the commit hash of HEAD after the operation.
|
||||
func (s *Service) CloneOrPull(url, branch, username, password, dstDir string) (string, error) {
|
||||
// Check if repo already exists
|
||||
repo, err := gogit.PlainOpen(dstDir)
|
||||
if err == nil {
|
||||
// Repo exists, pull
|
||||
return s.pull(repo, branch, username, password)
|
||||
}
|
||||
|
||||
// Clone fresh
|
||||
return s.clone(url, branch, username, password, dstDir)
|
||||
}
|
||||
|
||||
func (s *Service) clone(url, branch, username, password, dstDir string) (string, error) {
|
||||
// Ensure parent directory exists
|
||||
os.MkdirAll(filepath.Dir(dstDir), 0755)
|
||||
// Remove target if it exists (shouldn't, but be safe)
|
||||
os.RemoveAll(dstDir)
|
||||
|
||||
var auth transport.AuthMethod
|
||||
if username != "" || password != "" {
|
||||
auth = &http.BasicAuth{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
refName := plumbing.NewBranchReferenceName(branch)
|
||||
repo, err := gogit.PlainClone(dstDir, false, &gogit.CloneOptions{
|
||||
URL: url,
|
||||
Auth: auth,
|
||||
ReferenceName: refName,
|
||||
SingleBranch: true,
|
||||
Depth: 1,
|
||||
Progress: nil,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("clone failed: %w", err)
|
||||
}
|
||||
|
||||
return getHeadHash(repo)
|
||||
}
|
||||
|
||||
func (s *Service) pull(repo *gogit.Repository, branch, username, password string) (string, error) {
|
||||
w, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("worktree: %w", err)
|
||||
}
|
||||
|
||||
var auth transport.AuthMethod
|
||||
if username != "" || password != "" {
|
||||
auth = &http.BasicAuth{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
}
|
||||
|
||||
refName := plumbing.NewBranchReferenceName(branch)
|
||||
err = w.Pull(&gogit.PullOptions{
|
||||
RemoteName: "origin",
|
||||
Auth: auth,
|
||||
ReferenceName: refName,
|
||||
SingleBranch: true,
|
||||
})
|
||||
if err != nil && err != gogit.NoErrAlreadyUpToDate {
|
||||
return "", fmt.Errorf("pull failed: %w", err)
|
||||
}
|
||||
|
||||
return getHeadHash(repo)
|
||||
}
|
||||
|
||||
func getHeadHash(repo *gogit.Repository) (string, error) {
|
||||
ref, err := repo.Head()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ref.Hash().String(), nil
|
||||
}
|
||||
187
internal/handler/build.go
Normal file
187
internal/handler/build.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"context"
|
||||
|
||||
"ci/internal/model"
|
||||
"ci/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// BuildHandler handles build/run/log endpoints.
|
||||
type BuildHandler struct {
|
||||
Svc *service.ProjectService
|
||||
}
|
||||
|
||||
func NewBuildHandler(svc *service.ProjectService) *BuildHandler {
|
||||
return &BuildHandler{Svc: svc}
|
||||
}
|
||||
|
||||
// TriggerBuild starts a build for the project.
|
||||
func (h *BuildHandler) TriggerBuild(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build in background
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
h.Svc.BuildProject(ctx, id)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "build started"})
|
||||
}
|
||||
|
||||
// StartProject starts the project's binary.
|
||||
func (h *BuildHandler) StartProject(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.Svc.StartProject(ctx, id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "started"})
|
||||
}
|
||||
|
||||
// StopProject stops a running project.
|
||||
func (h *BuildHandler) StopProject(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Svc.StopProject(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "stopped"})
|
||||
}
|
||||
|
||||
// RestartProject restarts a project.
|
||||
func (h *BuildHandler) RestartProject(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.Svc.RestartProject(ctx, id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "restarted"})
|
||||
}
|
||||
|
||||
// GetBuilds lists build history for a project.
|
||||
func (h *BuildHandler) GetBuilds(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
records, err := h.Svc.GetBuildHistory(id, 50)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if records == nil {
|
||||
records = []model.BuildRecord{}
|
||||
}
|
||||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
|
||||
// GetBuildLog returns the full log for a specific build.
|
||||
func (h *BuildHandler) GetBuildLog(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid build id"})
|
||||
return
|
||||
}
|
||||
|
||||
logContent, err := h.Svc.GetBuildLog(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, logContent)
|
||||
}
|
||||
|
||||
// StreamLogs streams real-time logs via SSE for a project.
|
||||
func (h *BuildHandler) StreamLogs(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
|
||||
ch := h.Svc.SubscribeLogs(id)
|
||||
defer h.Svc.UnsubscribeLogs(id, ch)
|
||||
|
||||
clientGone := c.Request.Context().Done()
|
||||
|
||||
c.Stream(func(w io.Writer) bool {
|
||||
select {
|
||||
case <-clientGone:
|
||||
return false
|
||||
case line, ok := <-ch:
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", line)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Webhook handles Git webhook triggers (Gitee/GitHub).
|
||||
func (h *BuildHandler) Webhook(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify project exists
|
||||
_, err = h.Svc.GetProject(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger build
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
h.Svc.BuildProject(ctx, id)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "webhook received, build triggered"})
|
||||
}
|
||||
151
internal/handler/deploy.go
Normal file
151
internal/handler/deploy.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"ci/internal/model"
|
||||
"ci/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// DeployHandler handles deployment endpoints.
|
||||
type DeployHandler struct {
|
||||
Svc *service.ProjectService
|
||||
}
|
||||
|
||||
// NewDeployHandler creates a new DeployHandler.
|
||||
func NewDeployHandler(svc *service.ProjectService) *DeployHandler {
|
||||
return &DeployHandler{Svc: svc}
|
||||
}
|
||||
|
||||
// GetDeployConfig returns the deploy config for a project.
|
||||
func (h *DeployHandler) GetDeployConfig(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.Svc.GetDeployConfig(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no deploy config"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, cfg)
|
||||
}
|
||||
|
||||
// UpdateDeployConfig creates or updates the deploy config for a project.
|
||||
func (h *DeployHandler) UpdateDeployConfig(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
var cfg model.DeployConfig
|
||||
if err := c.ShouldBindJSON(&cfg); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
cfg.ProjectID = id
|
||||
|
||||
if err := h.Svc.UpdateDeployConfig(&cfg); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Re-fetch to return clean state (sensitive fields hidden by json:"-")
|
||||
result, _ := h.Svc.GetDeployConfig(id)
|
||||
if result == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "created"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// DeleteDeployConfig removes the deploy config for a project.
|
||||
func (h *DeployHandler) DeleteDeployConfig(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.Svc.DeleteDeployConfig(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deploy config deleted"})
|
||||
}
|
||||
|
||||
// TriggerDeploy starts a deployment for the project.
|
||||
func (h *DeployHandler) TriggerDeploy(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Deploy in background
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
go func() {
|
||||
h.Svc.TriggerDeploy(ctx, id, nil)
|
||||
}()
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{"message": "deployment started"})
|
||||
}
|
||||
|
||||
// GetDeployRecords lists deploy history for a project.
|
||||
func (h *DeployHandler) GetDeployRecords(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
records, err := h.Svc.GetDeployHistory(id, 50)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if records == nil {
|
||||
records = []model.DeployRecord{}
|
||||
}
|
||||
c.JSON(http.StatusOK, records)
|
||||
}
|
||||
|
||||
// GetDeployLog returns the full log for a specific deployment.
|
||||
func (h *DeployHandler) GetDeployLog(c *gin.Context) {
|
||||
didStr := c.Param("did")
|
||||
did, err := parseIDStr(didStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid deploy id"})
|
||||
return
|
||||
}
|
||||
|
||||
logContent, err := h.Svc.GetDeployLog(did)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.String(http.StatusOK, logContent)
|
||||
}
|
||||
|
||||
// parseIDStr parses an ID string for non-param-based IDs (e.g., deploy record ID from URL path).
|
||||
func parseIDStr(s string) (uint, error) {
|
||||
id, err := strconv.ParseUint(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
129
internal/handler/project.go
Normal file
129
internal/handler/project.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"ci/internal/model"
|
||||
"ci/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ProjectHandler handles project CRUD endpoints.
|
||||
type ProjectHandler struct {
|
||||
Svc *service.ProjectService
|
||||
}
|
||||
|
||||
func NewProjectHandler(svc *service.ProjectService) *ProjectHandler {
|
||||
return &ProjectHandler{Svc: svc}
|
||||
}
|
||||
|
||||
// ListProjects returns all projects.
|
||||
func (h *ProjectHandler) ListProjects(c *gin.Context) {
|
||||
projects, err := h.Svc.ListProjects()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if projects == nil {
|
||||
projects = []model.Project{}
|
||||
}
|
||||
c.JSON(http.StatusOK, projects)
|
||||
}
|
||||
|
||||
// GetProject returns a single project by ID.
|
||||
func (h *ProjectHandler) GetProject(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
project, err := h.Svc.GetProject(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, project)
|
||||
}
|
||||
|
||||
// CreateProject creates a new project.
|
||||
func (h *ProjectHandler) CreateProject(c *gin.Context) {
|
||||
var p model.Project
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if p.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
|
||||
return
|
||||
}
|
||||
if p.Mode != "git" && p.Mode != "upload" {
|
||||
p.Mode = "git" // default
|
||||
}
|
||||
if err := h.Svc.CreateProject(&p); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, p)
|
||||
}
|
||||
|
||||
// UpdateProject updates an existing project.
|
||||
func (h *ProjectHandler) UpdateProject(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := h.Svc.GetProject(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var updates model.Project
|
||||
if err := c.ShouldBindJSON(&updates); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve fields that should not be updated from the request
|
||||
updates.ID = existing.ID
|
||||
updates.CreatedAt = existing.CreatedAt
|
||||
updates.Status = existing.Status
|
||||
updates.PID = existing.PID
|
||||
// Preserve password if not sent
|
||||
if updates.GitPassword == "" {
|
||||
updates.GitPassword = existing.GitPassword
|
||||
}
|
||||
|
||||
if err := h.Svc.UpdateProject(&updates); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, updates)
|
||||
}
|
||||
|
||||
// DeleteProject deletes a project.
|
||||
func (h *ProjectHandler) DeleteProject(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
if err := h.Svc.DeleteProject(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
func parseID(c *gin.Context) (uint, error) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
76
internal/handler/upload.go
Normal file
76
internal/handler/upload.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"ci/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UploadHandler handles file upload endpoints.
|
||||
type UploadHandler struct {
|
||||
Svc *service.ProjectService
|
||||
}
|
||||
|
||||
func NewUploadHandler(svc *service.ProjectService) *UploadHandler {
|
||||
return &UploadHandler{Svc: svc}
|
||||
}
|
||||
|
||||
// UploadFiles handles source file upload for a project (zip/tar.gz).
|
||||
func (h *UploadHandler) UploadFiles(c *gin.Context) {
|
||||
id, err := parseID(c)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify project exists and is upload mode
|
||||
project, err := h.Svc.GetProject(id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
|
||||
return
|
||||
}
|
||||
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file extension
|
||||
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||
valid := false
|
||||
if ext == ".zip" {
|
||||
valid = true
|
||||
}
|
||||
if strings.HasSuffix(strings.ToLower(file.Filename), ".tar.gz") || strings.HasSuffix(strings.ToLower(file.Filename), ".tgz") {
|
||||
valid = true
|
||||
}
|
||||
|
||||
if !valid {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "only .zip and .tar.gz files are supported"})
|
||||
return
|
||||
}
|
||||
|
||||
// Save uploaded file to temp location
|
||||
tmpPath := filepath.Join(h.Svc.Workspace.ProjectDir(project.ID), file.Filename)
|
||||
if err := c.SaveUploadedFile(file, tmpPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save file: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract to src directory
|
||||
if err := h.Svc.Workspace.ExtractArchive(project.ID, tmpPath); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to extract: %v", err)})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "file uploaded and extracted successfully",
|
||||
"filename": file.Filename,
|
||||
})
|
||||
}
|
||||
88
internal/model/model.go
Normal file
88
internal/model/model.go
Normal file
@@ -0,0 +1,88 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Project represents a managed CI/CD project
|
||||
type Project struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;not null" json:"name"`
|
||||
Description string `json:"description"`
|
||||
Mode string `gorm:"default:'git'" json:"mode"` // "git" or "upload"
|
||||
|
||||
// Git mode fields
|
||||
GitURL string `json:"git_url"`
|
||||
GitBranch string `gorm:"default:'master'" json:"git_branch"`
|
||||
GitUsername string `json:"git_username"`
|
||||
GitPassword string `json:"-"` // hidden from JSON responses
|
||||
|
||||
// Build config
|
||||
BuildScript string `json:"build_script"`
|
||||
OutputBinary string `json:"output_binary"` // relative path to built binary
|
||||
|
||||
// Runtime config
|
||||
RunCommand string `json:"run_command"` // command to run the project
|
||||
EnvVars string `json:"env_vars"` // JSON key-value env vars
|
||||
Port int `json:"port"` // target port
|
||||
|
||||
// Status
|
||||
Status string `json:"status"` // "stopped", "running", "building", "error"
|
||||
PID int `json:"pid"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// BuildRecord represents a single build execution
|
||||
type BuildRecord struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ProjectID uint `gorm:"index;not null" json:"project_id"`
|
||||
Project *Project `gorm:"foreignKey:ProjectID" json:"project,omitempty"`
|
||||
Status string `gorm:"default:'running'" json:"status"` // "running", "success", "failed"
|
||||
LogPath string `json:"log_path"` // path to log file
|
||||
CommitHash string `json:"commit_hash"` // git commit hash (if git mode)
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
}
|
||||
|
||||
// DeployConfig holds SSH deployment configuration for a project.
|
||||
type DeployConfig struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ProjectID uint `gorm:"uniqueIndex;not null" json:"project_id"`
|
||||
|
||||
// SSH connection
|
||||
Host string `json:"host"`
|
||||
Port int `gorm:"default:22" json:"port"`
|
||||
Username string `json:"username"`
|
||||
AuthMethod string `gorm:"default:'key'" json:"auth_method"` // "key" or "password"
|
||||
SSHKey string `json:"-"` // hidden from JSON responses
|
||||
SSHPassword string `json:"-"` // hidden from JSON responses
|
||||
|
||||
// Deployment settings
|
||||
DeployDir string `json:"deploy_dir"` // remote target directory
|
||||
FileMappings string `json:"file_mappings"` // JSON: [{"local":"app","remote":"/opt/myapp/app"}]
|
||||
|
||||
PreDeployCommand string `json:"pre_deploy_command"`
|
||||
PostDeployCommand string `json:"post_deploy_command"`
|
||||
|
||||
// Health check
|
||||
HealthCheckURL string `json:"health_check_url"`
|
||||
HealthCheckTimeout int `gorm:"default:30" json:"health_check_timeout"`
|
||||
|
||||
// Auto-deploy after successful build
|
||||
AutoDeploy bool `gorm:"default:false" json:"auto_deploy"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DeployRecord tracks a single deployment execution.
|
||||
type DeployRecord struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ProjectID uint `gorm:"index;not null" json:"project_id"`
|
||||
Project *Project `gorm:"foreignKey:ProjectID" json:"project,omitempty"`
|
||||
BuildRecordID *uint `json:"build_record_id"` // optional link to the build that triggered this deploy
|
||||
Status string `gorm:"default:'running'" json:"status"` // "running", "success", "failed"
|
||||
LogPath string `json:"log_path"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at"`
|
||||
}
|
||||
279
internal/process/manager.go
Normal file
279
internal/process/manager.go
Normal file
@@ -0,0 +1,279 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LineSubscriber receives individual log lines as they arrive.
|
||||
type LineSubscriber func(line string)
|
||||
|
||||
// ManagedProcess wraps a running child process with log streaming.
|
||||
type ManagedProcess struct {
|
||||
mu sync.RWMutex
|
||||
cmd *exec.Cmd
|
||||
PID int
|
||||
ProjectID uint
|
||||
Status string // "running", "stopped"
|
||||
subscribers []LineSubscriber
|
||||
logFile *os.File
|
||||
cancel chan struct{}
|
||||
}
|
||||
|
||||
// Manager manages multiple running processes keyed by project ID.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
procs map[uint]*ManagedProcess // projectID -> process
|
||||
stopChan chan struct{}
|
||||
}
|
||||
|
||||
// NewManager creates a new process Manager.
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
procs: make(map[uint]*ManagedProcess),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches a command for the given project and streams stdout/stderr.
|
||||
func (m *Manager) Start(projectID uint, workDir string, command string, envVars map[string]string, logWriter io.Writer) (*ManagedProcess, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Stop existing process for this project if any
|
||||
if existing, ok := m.procs[projectID]; ok {
|
||||
existing.Stop()
|
||||
}
|
||||
|
||||
// Parse command: support simple "cmd arg1 arg2" or shell execution
|
||||
var cmd *exec.Cmd
|
||||
if isShellCommand(command) {
|
||||
cmd = exec.Command("sh", "-c", command)
|
||||
} else {
|
||||
parts := splitCommand(command)
|
||||
cmd = exec.Command(parts[0], parts[1:]...)
|
||||
}
|
||||
|
||||
cmd.Dir = workDir
|
||||
cmd.Env = os.Environ()
|
||||
for k, v := range envVars {
|
||||
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
|
||||
}
|
||||
|
||||
// Pipe stdout and stderr
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start process: %w", err)
|
||||
}
|
||||
|
||||
mp := &ManagedProcess{
|
||||
cmd: cmd,
|
||||
PID: cmd.Process.Pid,
|
||||
ProjectID: projectID,
|
||||
Status: "running",
|
||||
cancel: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Stream stdout/stderr to subscribers and log writer
|
||||
go mp.streamOutput(stdout, logWriter)
|
||||
go mp.streamOutput(stderr, logWriter)
|
||||
|
||||
// Monitor process exit
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
mp.mu.Lock()
|
||||
mp.Status = "stopped"
|
||||
if err != nil {
|
||||
mp.broadcast(fmt.Sprintf("[Process exited with error: %v]", err))
|
||||
} else {
|
||||
mp.broadcast("[Process exited successfully]")
|
||||
}
|
||||
mp.mu.Unlock()
|
||||
delete(m.procs, projectID)
|
||||
}()
|
||||
|
||||
m.procs[projectID] = mp
|
||||
return mp, nil
|
||||
}
|
||||
|
||||
// Stop terminates a running process by project ID.
|
||||
func (m *Manager) Stop(projectID uint) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
mp, ok := m.procs[projectID]
|
||||
if !ok {
|
||||
return fmt.Errorf("no running process for project %d", projectID)
|
||||
}
|
||||
return mp.Stop()
|
||||
}
|
||||
|
||||
// Stop terminates the managed process.
|
||||
func (mp *ManagedProcess) Stop() error {
|
||||
mp.mu.Lock()
|
||||
defer mp.mu.Unlock()
|
||||
|
||||
if mp.Status == "stopped" {
|
||||
return nil
|
||||
}
|
||||
|
||||
close(mp.cancel)
|
||||
|
||||
// Try graceful shutdown first
|
||||
if err := mp.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
// On Windows, SIGTERM is not supported; use Kill
|
||||
mp.cmd.Process.Kill()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait with timeout
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := mp.cmd.Process.Wait()
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
mp.Status = "stopped"
|
||||
return nil
|
||||
case <-time.After(10 * time.Second):
|
||||
mp.cmd.Process.Kill()
|
||||
mp.Status = "stopped"
|
||||
return fmt.Errorf("process %d killed after timeout", mp.PID)
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatus returns the status of a project's process.
|
||||
func (m *Manager) GetStatus(projectID uint) string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
mp, ok := m.procs[projectID]
|
||||
if !ok {
|
||||
return "stopped"
|
||||
}
|
||||
|
||||
mp.mu.RLock()
|
||||
defer mp.mu.RUnlock()
|
||||
return mp.Status
|
||||
}
|
||||
|
||||
// GetProcess returns the managed process for a project, or nil.
|
||||
func (m *Manager) GetProcess(projectID uint) *ManagedProcess {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.procs[projectID]
|
||||
}
|
||||
|
||||
// Subscribe adds a log line subscriber to a running process.
|
||||
func (mp *ManagedProcess) Subscribe(fn LineSubscriber) {
|
||||
mp.mu.Lock()
|
||||
defer mp.mu.Unlock()
|
||||
mp.subscribers = append(mp.subscribers, fn)
|
||||
}
|
||||
|
||||
// UnsubscribeAll removes all subscribers.
|
||||
func (mp *ManagedProcess) UnsubscribeAll() {
|
||||
mp.mu.Lock()
|
||||
defer mp.mu.Unlock()
|
||||
mp.subscribers = nil
|
||||
}
|
||||
|
||||
// IsRunning returns whether the process is currently running.
|
||||
func (mp *ManagedProcess) IsRunning() bool {
|
||||
mp.mu.RLock()
|
||||
defer mp.mu.RUnlock()
|
||||
return mp.Status == "running"
|
||||
}
|
||||
|
||||
func (mp *ManagedProcess) broadcast(line string) {
|
||||
for _, sub := range mp.subscribers {
|
||||
sub(line)
|
||||
}
|
||||
}
|
||||
|
||||
func (mp *ManagedProcess) streamOutput(r io.Reader, logWriter io.Writer) {
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 1024*1024), 1024*1024) // 1MB buffer
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
mp.mu.RLock()
|
||||
mp.broadcast(line)
|
||||
mp.mu.RUnlock()
|
||||
if logWriter != nil {
|
||||
fmt.Fprintln(logWriter, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops all managed processes gracefully.
|
||||
func (m *Manager) Shutdown() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for id, mp := range m.procs {
|
||||
mp.Stop()
|
||||
delete(m.procs, id)
|
||||
}
|
||||
}
|
||||
|
||||
// isShellCommand returns true if the command contains shell metacharacters.
|
||||
func isShellCommand(cmd string) bool {
|
||||
for _, c := range cmd {
|
||||
switch c {
|
||||
case '|', '&', ';', '$', '>', '<', '`', '*', '?', '[', ']', '(', ')', '{', '}':
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// splitCommand splits a simple command string into parts, respecting quotes.
|
||||
func splitCommand(cmd string) []string {
|
||||
var parts []string
|
||||
var current []rune
|
||||
inQuote := false
|
||||
quoteChar := rune(0)
|
||||
|
||||
for _, r := range cmd {
|
||||
switch {
|
||||
case r == '"' || r == '\'':
|
||||
if inQuote && r == quoteChar {
|
||||
inQuote = false
|
||||
} else if !inQuote {
|
||||
inQuote = true
|
||||
quoteChar = r
|
||||
} else {
|
||||
current = append(current, r)
|
||||
}
|
||||
case r == ' ' && !inQuote:
|
||||
if len(current) > 0 {
|
||||
parts = append(parts, string(current))
|
||||
current = nil
|
||||
}
|
||||
default:
|
||||
current = append(current, r)
|
||||
}
|
||||
}
|
||||
if len(current) > 0 {
|
||||
parts = append(parts, string(current))
|
||||
}
|
||||
return parts
|
||||
}
|
||||
184
internal/server/server.go
Normal file
184
internal/server/server.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"ci/internal/handler"
|
||||
"ci/internal/service"
|
||||
"ci/internal/store"
|
||||
"ci/internal/workspace"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Config holds server configuration.
|
||||
type Config struct {
|
||||
Port string
|
||||
DataDir string
|
||||
}
|
||||
|
||||
// DefaultConfig returns the default server configuration.
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Port: "8080",
|
||||
DataDir: "data",
|
||||
}
|
||||
}
|
||||
|
||||
// Server wraps the Gin engine and related services.
|
||||
type Server struct {
|
||||
engine *gin.Engine
|
||||
config Config
|
||||
svc *service.ProjectService
|
||||
}
|
||||
|
||||
// New creates a new Server with the given config and embedded frontend.
|
||||
// Pass nil for frontendFS when using local file serving (dev mode).
|
||||
func New(config Config, frontendFS *embed.FS) (*Server, error) {
|
||||
// Resolve absolute data directory
|
||||
absDataDir, err := filepath.Abs(config.DataDir)
|
||||
if err != nil {
|
||||
absDataDir = config.DataDir
|
||||
}
|
||||
|
||||
// Initialize store
|
||||
st, err := store.New(absDataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize workspace
|
||||
workspaceDir := filepath.Join(absDataDir, "workspaces")
|
||||
wm, err := workspace.New(workspaceDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize service
|
||||
svc := service.NewProjectService(st, wm)
|
||||
|
||||
// Initialize handlers
|
||||
projectH := handler.NewProjectHandler(svc)
|
||||
buildH := handler.NewBuildHandler(svc)
|
||||
uploadH := handler.NewUploadHandler(svc)
|
||||
deployH := handler.NewDeployHandler(svc)
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.Default()
|
||||
|
||||
// CORS middleware for development
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Header("Access-Control-Allow-Origin", "*")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||
if c.Request.Method == "OPTIONS" {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Health check
|
||||
r.GET("/api/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
// API routes
|
||||
api := r.Group("/api")
|
||||
{
|
||||
// Projects CRUD
|
||||
api.GET("/projects", projectH.ListProjects)
|
||||
api.POST("/projects", projectH.CreateProject)
|
||||
api.GET("/projects/:id", projectH.GetProject)
|
||||
api.PUT("/projects/:id", projectH.UpdateProject)
|
||||
api.DELETE("/projects/:id", projectH.DeleteProject)
|
||||
|
||||
// Build & Run
|
||||
api.POST("/projects/:id/build", buildH.TriggerBuild)
|
||||
api.POST("/projects/:id/start", buildH.StartProject)
|
||||
api.POST("/projects/:id/stop", buildH.StopProject)
|
||||
api.POST("/projects/:id/restart", buildH.RestartProject)
|
||||
api.GET("/projects/:id/builds", buildH.GetBuilds)
|
||||
api.GET("/projects/:id/logs/:buildId", buildH.GetBuildLog)
|
||||
api.GET("/projects/:id/logs/stream", buildH.StreamLogs)
|
||||
|
||||
// Deploy config
|
||||
api.GET("/projects/:id/deploy-config", deployH.GetDeployConfig)
|
||||
api.PUT("/projects/:id/deploy-config", deployH.UpdateDeployConfig)
|
||||
api.DELETE("/projects/:id/deploy-config", deployH.DeleteDeployConfig)
|
||||
|
||||
// Deploy execution
|
||||
api.POST("/projects/:id/deploy", deployH.TriggerDeploy)
|
||||
api.GET("/projects/:id/deploy-records", deployH.GetDeployRecords)
|
||||
api.GET("/projects/:id/deploy-records/:did/log", deployH.GetDeployLog)
|
||||
|
||||
// Upload
|
||||
api.POST("/projects/:id/upload", uploadH.UploadFiles)
|
||||
|
||||
// Webhook
|
||||
api.POST("/webhook/:id", buildH.Webhook)
|
||||
}
|
||||
|
||||
// Serve frontend
|
||||
if frontendFS != nil {
|
||||
// Embedded mode: serve from embedded dist files
|
||||
distFS, err := fs.Sub(frontendFS, "web/dist")
|
||||
if err != nil {
|
||||
// Try without the prefix
|
||||
distFS = frontendFS
|
||||
}
|
||||
|
||||
staticFS, err := fs.Sub(distFS, ".")
|
||||
_ = staticFS
|
||||
_ = err
|
||||
|
||||
// Use gin's static file serving with embedded FS
|
||||
r.NoRoute(gin.WrapH(http.FileServer(http.FS(distFS))))
|
||||
} else {
|
||||
// Dev mode: try serving from web/dist directory
|
||||
localDist := "web/dist"
|
||||
if _, err := os.Stat(localDist); err == nil {
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if path == "/" {
|
||||
c.File(filepath.Join(localDist, "index.html"))
|
||||
return
|
||||
}
|
||||
// Try the specific file first
|
||||
targetPath := filepath.Join(localDist, path)
|
||||
if _, err := os.Stat(targetPath); err == nil {
|
||||
c.File(targetPath)
|
||||
return
|
||||
}
|
||||
// SPA fallback
|
||||
c.File(filepath.Join(localDist, "index.html"))
|
||||
})
|
||||
} else {
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.String(http.StatusOK, "CI/CD Server is running. Frontend not yet built — run `cd web && npm run build`.")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
srv := &Server{
|
||||
engine: r,
|
||||
config: config,
|
||||
svc: svc,
|
||||
}
|
||||
|
||||
return srv, nil
|
||||
}
|
||||
|
||||
// Run starts the HTTP server.
|
||||
func (s *Server) Run() error {
|
||||
return s.engine.Run(":" + s.config.Port)
|
||||
}
|
||||
|
||||
// Shutdown gracefully shuts down the server.
|
||||
func (s *Server) Shutdown() {
|
||||
s.svc.Shutdown()
|
||||
}
|
||||
549
internal/service/project.go
Normal file
549
internal/service/project.go
Normal file
@@ -0,0 +1,549 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ci/internal/builder"
|
||||
"ci/internal/deployer"
|
||||
gitsvc "ci/internal/git"
|
||||
"ci/internal/model"
|
||||
"ci/internal/process"
|
||||
"ci/internal/store"
|
||||
"ci/internal/workspace"
|
||||
)
|
||||
|
||||
// ProjectService handles all business logic for project management.
|
||||
type ProjectService struct {
|
||||
Store *store.Store
|
||||
Workspace *workspace.Manager
|
||||
Git *gitsvc.Service
|
||||
Builder *builder.Service
|
||||
Process *process.Manager
|
||||
Deployer *deployer.Service
|
||||
|
||||
// SSE subscribers for log streaming
|
||||
mu sync.RWMutex
|
||||
subscribers map[uint][]chan string // projectID -> list of subscriber channels
|
||||
}
|
||||
|
||||
// NewProjectService creates a new ProjectService.
|
||||
func NewProjectService(s *store.Store, w *workspace.Manager) *ProjectService {
|
||||
return &ProjectService{
|
||||
Store: s,
|
||||
Workspace: w,
|
||||
Git: gitsvc.New(),
|
||||
Builder: builder.New(),
|
||||
Process: process.NewManager(),
|
||||
Deployer: deployer.New(),
|
||||
subscribers: make(map[uint][]chan string),
|
||||
}
|
||||
}
|
||||
|
||||
// CreateProject creates a new project and initializes its workspace.
|
||||
func (s *ProjectService) CreateProject(p *model.Project) error {
|
||||
p.Status = "stopped"
|
||||
if err := s.Store.CreateProject(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Workspace.InitProject(p.ID)
|
||||
}
|
||||
|
||||
// ListProjects returns all projects.
|
||||
func (s *ProjectService) ListProjects() ([]model.Project, error) {
|
||||
projects, err := s.Store.ListProjects()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Update status from process manager
|
||||
for i := range projects {
|
||||
status := s.Process.GetStatus(projects[i].ID)
|
||||
if status == "running" {
|
||||
projects[i].Status = "running"
|
||||
} else if projects[i].Status == "running" {
|
||||
projects[i].Status = "stopped"
|
||||
}
|
||||
}
|
||||
return projects, nil
|
||||
}
|
||||
|
||||
// GetProject returns a single project.
|
||||
func (s *ProjectService) GetProject(id uint) (*model.Project, error) {
|
||||
p, err := s.Store.GetProject(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
status := s.Process.GetStatus(id)
|
||||
if status == "running" {
|
||||
p.Status = "running"
|
||||
} else if p.Status == "running" {
|
||||
p.Status = "stopped"
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// UpdateProject updates a project's configuration.
|
||||
func (s *ProjectService) UpdateProject(p *model.Project) error {
|
||||
return s.Store.UpdateProject(p)
|
||||
}
|
||||
|
||||
// DeleteProject deletes a project, stops its process, and cleans up its workspace.
|
||||
func (s *ProjectService) DeleteProject(id uint) error {
|
||||
s.Process.Stop(id)
|
||||
s.Workspace.CleanProject(id)
|
||||
s.Store.DeleteDeployConfig(id) // clean up deploy config if exists
|
||||
return s.Store.DeleteProject(id)
|
||||
}
|
||||
|
||||
// BuildProject triggers a build for the project.
|
||||
func (s *ProjectService) BuildProject(ctx context.Context, projectID uint) (*model.BuildRecord, error) {
|
||||
project, err := s.Store.GetProject(projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Start a build record
|
||||
now := time.Now()
|
||||
record := &model.BuildRecord{
|
||||
ProjectID: projectID,
|
||||
Status: "running",
|
||||
StartedAt: now,
|
||||
}
|
||||
if err := s.Store.CreateBuildRecord(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Update project status
|
||||
project.Status = "building"
|
||||
s.Store.UpdateProject(project)
|
||||
|
||||
// For git mode, pull/clone first
|
||||
var commitHash string
|
||||
if project.Mode == "git" && project.GitURL != "" {
|
||||
srcDir := s.Workspace.SrcDir(projectID)
|
||||
hash, err := s.Git.CloneOrPull(project.GitURL, project.GitBranch, project.GitUsername, project.GitPassword, srcDir)
|
||||
if err != nil {
|
||||
s.finishBuild(record, project, "failed", err.Error(), "")
|
||||
return record, err
|
||||
}
|
||||
commitHash = hash
|
||||
record.CommitHash = hash
|
||||
s.Store.UpdateBuildRecord(record)
|
||||
}
|
||||
|
||||
// Execute build
|
||||
srcDir := s.Workspace.SrcDir(projectID)
|
||||
logsDir := s.Workspace.LogsDir(projectID)
|
||||
|
||||
// Notify SSE subscribers about the build starting
|
||||
s.broadcast(projectID, fmt.Sprintf("[Build #%d] Starting build for project %s...", record.ID, project.Name))
|
||||
|
||||
result, err := s.Builder.Execute(ctx, project, srcDir, logsDir, commitHash, func(line string) {
|
||||
s.broadcast(projectID, line)
|
||||
})
|
||||
|
||||
if err != nil || !result.Success {
|
||||
errMsg := "build failed"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
if result != nil && result.Output != "" {
|
||||
errMsg = result.Output
|
||||
}
|
||||
s.finishBuild(record, project, "failed", errMsg, result.LogPath)
|
||||
if err != nil {
|
||||
return record, err
|
||||
}
|
||||
return record, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
finishTime := time.Now()
|
||||
record.Status = "success"
|
||||
record.LogPath = result.LogPath
|
||||
record.CommitHash = result.CommitHash
|
||||
record.FinishedAt = &finishTime
|
||||
s.Store.UpdateBuildRecord(record)
|
||||
|
||||
project.Status = "stopped"
|
||||
s.Store.UpdateProject(project)
|
||||
|
||||
s.broadcast(projectID, fmt.Sprintf("[Build #%d] Build completed successfully", record.ID))
|
||||
|
||||
// Auto-deploy if configured
|
||||
if autoCfg, _ := s.Store.GetDeployConfig(projectID); autoCfg != nil && autoCfg.AutoDeploy {
|
||||
s.broadcast(projectID, "[Deploy] Auto-deploy enabled, triggering deployment...")
|
||||
go func() {
|
||||
depCtx, depCancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer depCancel()
|
||||
s.TriggerDeploy(depCtx, projectID, &record.ID)
|
||||
}()
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// StartProject starts the built binary for a project.
|
||||
func (s *ProjectService) StartProject(ctx context.Context, projectID uint) error {
|
||||
project, err := s.Store.GetProject(projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if s.Process.GetStatus(projectID) == "running" {
|
||||
return fmt.Errorf("project %s is already running", project.Name)
|
||||
}
|
||||
|
||||
// Parse env vars from JSON string
|
||||
var envVars map[string]string
|
||||
if project.EnvVars != "" {
|
||||
json.Unmarshal([]byte(project.EnvVars), &envVars)
|
||||
}
|
||||
if envVars == nil {
|
||||
envVars = make(map[string]string)
|
||||
}
|
||||
|
||||
// Determine working directory
|
||||
workDir := s.Workspace.SrcDir(projectID)
|
||||
|
||||
// Determine the command to run
|
||||
runCommand := project.RunCommand
|
||||
if runCommand == "" {
|
||||
if project.OutputBinary != "" {
|
||||
runCommand = "./" + project.OutputBinary
|
||||
} else if project.Mode == "git" {
|
||||
runCommand = "./app"
|
||||
} else {
|
||||
return fmt.Errorf("no run command configured")
|
||||
}
|
||||
}
|
||||
|
||||
// If port is specified, add it as an env var (PORT)
|
||||
if project.Port > 0 {
|
||||
envVars["PORT"] = fmt.Sprintf("%d", project.Port)
|
||||
}
|
||||
|
||||
logsDir := s.Workspace.LogsDir(projectID)
|
||||
logFileName := fmt.Sprintf("%s/run-%d.log", logsDir, time.Now().UnixMilli())
|
||||
|
||||
// Create log file for run output
|
||||
logFile, err := os.Create(logFileName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create log file: %w", err)
|
||||
}
|
||||
|
||||
mp, err := s.Process.Start(projectID, workDir, runCommand, envVars, logFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Subscribe to process output for SSE
|
||||
mp.Subscribe(func(line string) {
|
||||
s.broadcast(projectID, line)
|
||||
})
|
||||
|
||||
project.Status = "running"
|
||||
project.PID = mp.PID
|
||||
s.Store.UpdateProject(project)
|
||||
|
||||
s.broadcast(projectID, fmt.Sprintf("[Process] Started with PID %d: %s", mp.PID, runCommand))
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopProject stops a running project process.
|
||||
func (s *ProjectService) StopProject(projectID uint) error {
|
||||
if err := s.Process.Stop(projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
project, err := s.Store.GetProject(projectID)
|
||||
if err == nil {
|
||||
project.Status = "stopped"
|
||||
project.PID = 0
|
||||
s.Store.UpdateProject(project)
|
||||
}
|
||||
|
||||
s.broadcast(projectID, "[Process] Stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestartProject stops and restarts a project.
|
||||
func (s *ProjectService) RestartProject(ctx context.Context, projectID uint) error {
|
||||
s.StopProject(projectID)
|
||||
// Brief pause to ensure port is freed
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return s.StartProject(ctx, projectID)
|
||||
}
|
||||
|
||||
// GetBuildHistory returns the build history for a project.
|
||||
func (s *ProjectService) GetBuildHistory(projectID uint, limit int) ([]model.BuildRecord, error) {
|
||||
return s.Store.ListBuildRecords(projectID, limit)
|
||||
}
|
||||
|
||||
// GetBuildLog returns the contents of a build log.
|
||||
func (s *ProjectService) GetBuildLog(buildID uint) (string, error) {
|
||||
record, err := s.Store.GetBuildRecord(buildID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.Builder.ReadLog(record.LogPath)
|
||||
}
|
||||
|
||||
// --- Deploy Config ---
|
||||
|
||||
// GetDeployConfig returns the deploy config for a project, or nil.
|
||||
func (s *ProjectService) GetDeployConfig(projectID uint) (*model.DeployConfig, error) {
|
||||
return s.Store.GetDeployConfig(projectID)
|
||||
}
|
||||
|
||||
// UpdateDeployConfig creates or updates the deploy config for a project.
|
||||
func (s *ProjectService) UpdateDeployConfig(cfg *model.DeployConfig) error {
|
||||
// Preserve sensitive fields if not sent
|
||||
if existing, err := s.Store.GetDeployConfig(cfg.ProjectID); err == nil && existing != nil {
|
||||
if cfg.SSHKey == "" {
|
||||
cfg.SSHKey = existing.SSHKey
|
||||
}
|
||||
if cfg.SSHPassword == "" {
|
||||
cfg.SSHPassword = existing.SSHPassword
|
||||
}
|
||||
}
|
||||
return s.Store.UpsertDeployConfig(cfg)
|
||||
}
|
||||
|
||||
// DeleteDeployConfig removes the deploy config for a project.
|
||||
func (s *ProjectService) DeleteDeployConfig(projectID uint) error {
|
||||
return s.Store.DeleteDeployConfig(projectID)
|
||||
}
|
||||
|
||||
// --- Deploy Execution ---
|
||||
|
||||
// TriggerDeploy starts a deployment for the project.
|
||||
func (s *ProjectService) TriggerDeploy(ctx context.Context, projectID uint, buildRecordID *uint) (*model.DeployRecord, error) {
|
||||
_, err := s.Store.GetProject(projectID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("project not found: %w", err)
|
||||
}
|
||||
|
||||
cfg, err := s.Store.GetDeployConfig(projectID)
|
||||
if err != nil || cfg == nil {
|
||||
return nil, fmt.Errorf("deploy config not found for project %d", projectID)
|
||||
}
|
||||
|
||||
// Create deploy record
|
||||
now := time.Now()
|
||||
record := &model.DeployRecord{
|
||||
ProjectID: projectID,
|
||||
BuildRecordID: buildRecordID,
|
||||
Status: "running",
|
||||
StartedAt: now,
|
||||
}
|
||||
if err := s.Store.CreateDeployRecord(record); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Log file path
|
||||
logsDir := s.Workspace.LogsDir(projectID)
|
||||
logFileName := fmt.Sprintf("deploy-%d.log", time.Now().UnixMilli())
|
||||
logPath := filepath.Join(logsDir, logFileName)
|
||||
|
||||
// Parse file mappings
|
||||
var mappings []deployer.FileMapping
|
||||
if cfg.FileMappings != "" {
|
||||
if err := json.Unmarshal([]byte(cfg.FileMappings), &mappings); err != nil {
|
||||
s.finishDeploy(record, "failed", fmt.Sprintf("invalid file mappings: %v", err), logPath)
|
||||
return record, err
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve local file paths relative to src dir
|
||||
srcDir := s.Workspace.SrcDir(projectID)
|
||||
for i, m := range mappings {
|
||||
if !filepath.IsAbs(m.Local) {
|
||||
mappings[i].Local = filepath.Join(srcDir, m.Local)
|
||||
}
|
||||
}
|
||||
|
||||
// Build deployer config
|
||||
dCfg := &deployer.Config{
|
||||
Host: cfg.Host,
|
||||
Port: cfg.Port,
|
||||
Username: cfg.Username,
|
||||
AuthMethod: cfg.AuthMethod,
|
||||
SSHKey: cfg.SSHKey,
|
||||
Password: cfg.SSHPassword,
|
||||
}
|
||||
|
||||
// Create log file
|
||||
logFile, err := os.Create(logPath)
|
||||
if err != nil {
|
||||
s.finishDeploy(record, "failed", err.Error(), logPath)
|
||||
return record, err
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
// MultiWriter: write to both log file and SSE broadcast
|
||||
writer := io.MultiWriter(logFile, &deployLogWriter{fn: func(line string) {
|
||||
s.broadcast(projectID, "[Deploy] "+line)
|
||||
}})
|
||||
|
||||
s.broadcast(projectID, fmt.Sprintf("[Deploy #%d] Starting deployment to %s@%s:%d...",
|
||||
record.ID, cfg.Username, cfg.Host, cfg.Port))
|
||||
|
||||
// Execute deployment
|
||||
result, err := s.Deployer.Deploy(ctx, dCfg, cfg.DeployDir, mappings,
|
||||
cfg.PreDeployCommand, cfg.PostDeployCommand,
|
||||
cfg.HealthCheckURL, cfg.HealthCheckTimeout,
|
||||
func(line string) {
|
||||
fmt.Fprintln(writer, line)
|
||||
})
|
||||
|
||||
if err != nil || !result.Success {
|
||||
errMsg := "deploy failed"
|
||||
if err != nil {
|
||||
errMsg = err.Error()
|
||||
}
|
||||
if result != nil && result.Output != "" {
|
||||
errMsg = result.Output
|
||||
}
|
||||
s.finishDeploy(record, "failed", errMsg, logPath)
|
||||
if err != nil {
|
||||
return record, err
|
||||
}
|
||||
return record, fmt.Errorf("%s", errMsg)
|
||||
}
|
||||
|
||||
// Success
|
||||
finishTime := time.Now()
|
||||
record.Status = "success"
|
||||
record.LogPath = logPath
|
||||
record.FinishedAt = &finishTime
|
||||
s.Store.UpdateDeployRecord(record)
|
||||
|
||||
s.broadcast(projectID, fmt.Sprintf("[Deploy #%d] Deployment completed successfully", record.ID))
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// GetDeployHistory returns deploy history for a project.
|
||||
func (s *ProjectService) GetDeployHistory(projectID uint, limit int) ([]model.DeployRecord, error) {
|
||||
return s.Store.ListDeployRecords(projectID, limit)
|
||||
}
|
||||
|
||||
// GetDeployLog returns the full log for a specific deployment.
|
||||
func (s *ProjectService) GetDeployLog(deployID uint) (string, error) {
|
||||
record, err := s.Store.GetDeployRecord(deployID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(record.LogPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// deployLogWriter implements io.Writer for SSE broadcasting.
|
||||
type deployLogWriter struct {
|
||||
fn func(string)
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (w *deployLogWriter) Write(p []byte) (n int, err error) {
|
||||
if w.fn != nil {
|
||||
w.buf = append(w.buf, p...)
|
||||
for {
|
||||
idx := indexOfByteDeploy(w.buf, '\n')
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
line := string(w.buf[:idx])
|
||||
if len(line) > 0 && line[len(line)-1] == '\r' {
|
||||
line = line[:len(line)-1]
|
||||
}
|
||||
w.fn(line)
|
||||
w.buf = w.buf[idx+1:]
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func indexOfByteDeploy(data []byte, b byte) int {
|
||||
for i, c := range data {
|
||||
if c == b {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// SSE
|
||||
|
||||
// SubscribeLogs registers a channel to receive log lines for a project.
|
||||
func (s *ProjectService) SubscribeLogs(projectID uint) chan string {
|
||||
ch := make(chan string, 100)
|
||||
s.mu.Lock()
|
||||
s.subscribers[projectID] = append(s.subscribers[projectID], ch)
|
||||
s.mu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
// UnsubscribeLogs removes a subscriber channel for a project.
|
||||
func (s *ProjectService) UnsubscribeLogs(projectID uint, ch chan string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
subs := s.subscribers[projectID]
|
||||
for i, sub := range subs {
|
||||
if sub == ch {
|
||||
s.subscribers[projectID] = append(subs[:i], subs[i+1:]...)
|
||||
close(ch)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ProjectService) broadcast(projectID uint, line string) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
for _, ch := range s.subscribers[projectID] {
|
||||
select {
|
||||
case ch <- line:
|
||||
default:
|
||||
// Drop if channel is full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops all running processes.
|
||||
func (s *ProjectService) Shutdown() {
|
||||
s.Process.Shutdown()
|
||||
}
|
||||
|
||||
// helper
|
||||
|
||||
func (s *ProjectService) finishBuild(record *model.BuildRecord, project *model.Project, status, errMsg, logPath string) {
|
||||
now := time.Now()
|
||||
record.Status = status
|
||||
record.FinishedAt = &now
|
||||
if logPath != "" {
|
||||
record.LogPath = logPath
|
||||
}
|
||||
s.Store.UpdateBuildRecord(record)
|
||||
project.Status = "error"
|
||||
s.Store.UpdateProject(project)
|
||||
s.broadcast(project.ID, fmt.Sprintf("[Build #%d] %s", record.ID, errMsg))
|
||||
}
|
||||
|
||||
func (s *ProjectService) finishDeploy(record *model.DeployRecord, status, errMsg, logPath string) {
|
||||
now := time.Now()
|
||||
record.Status = status
|
||||
record.FinishedAt = &now
|
||||
if logPath != "" {
|
||||
record.LogPath = logPath
|
||||
}
|
||||
s.Store.UpdateDeployRecord(record)
|
||||
s.broadcast(record.ProjectID, fmt.Sprintf("[Deploy #%d] %s", record.ID, errMsg))
|
||||
}
|
||||
164
internal/store/store.go
Normal file
164
internal/store/store.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"ci/internal/model"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Store wraps the database connection and provides data access methods.
|
||||
type Store struct {
|
||||
DB *gorm.DB
|
||||
}
|
||||
|
||||
// New creates a new Store, initializes the database, and runs migrations.
|
||||
func New(dataDir string) (*Store, error) {
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dbPath := filepath.Join(dataDir, "ci.db")
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.AutoMigrate(&model.Project{}, &model.BuildRecord{}, &model.DeployConfig{}, &model.DeployRecord{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Store{DB: db}, nil
|
||||
}
|
||||
|
||||
// --- Project CRUD ---
|
||||
|
||||
func (s *Store) CreateProject(p *model.Project) error {
|
||||
return s.DB.Create(p).Error
|
||||
}
|
||||
|
||||
func (s *Store) ListProjects() ([]model.Project, error) {
|
||||
var projects []model.Project
|
||||
err := s.DB.Order("created_at desc").Find(&projects).Error
|
||||
return projects, err
|
||||
}
|
||||
|
||||
func (s *Store) GetProject(id uint) (*model.Project, error) {
|
||||
var p model.Project
|
||||
err := s.DB.First(&p, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdateProject(p *model.Project) error {
|
||||
return s.DB.Save(p).Error
|
||||
}
|
||||
|
||||
func (s *Store) DeleteProject(id uint) error {
|
||||
return s.DB.Delete(&model.Project{}, id).Error
|
||||
}
|
||||
|
||||
// --- BuildRecord CRUD ---
|
||||
|
||||
func (s *Store) CreateBuildRecord(r *model.BuildRecord) error {
|
||||
return s.DB.Create(r).Error
|
||||
}
|
||||
|
||||
func (s *Store) UpdateBuildRecord(r *model.BuildRecord) error {
|
||||
return s.DB.Save(r).Error
|
||||
}
|
||||
|
||||
func (s *Store) GetBuildRecord(id uint) (*model.BuildRecord, error) {
|
||||
var r model.BuildRecord
|
||||
err := s.DB.Preload("Project").First(&r, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListBuildRecords(projectID uint, limit int) ([]model.BuildRecord, error) {
|
||||
var records []model.BuildRecord
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
err := s.DB.Where("project_id = ?", projectID).
|
||||
Order("started_at desc").
|
||||
Limit(limit).
|
||||
Find(&records).Error
|
||||
return records, err
|
||||
}
|
||||
|
||||
// --- DeployConfig CRUD ---
|
||||
|
||||
// GetDeployConfig returns the deploy config for a project, or nil if not configured.
|
||||
func (s *Store) GetDeployConfig(projectID uint) (*model.DeployConfig, error) {
|
||||
var cfg model.DeployConfig
|
||||
err := s.DB.Where("project_id = ?", projectID).First(&cfg).Error
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, nil // not configured is not an error
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// UpsertDeployConfig creates or updates the deploy config for a project.
|
||||
func (s *Store) UpsertDeployConfig(cfg *model.DeployConfig) error {
|
||||
// Use FirstOrCreate + Assign to handle upsert
|
||||
var existing model.DeployConfig
|
||||
err := s.DB.Where("project_id = ?", cfg.ProjectID).First(&existing).Error
|
||||
if err == nil {
|
||||
// Update existing
|
||||
cfg.ID = existing.ID
|
||||
cfg.CreatedAt = existing.CreatedAt
|
||||
return s.DB.Save(cfg).Error
|
||||
}
|
||||
// Create new
|
||||
return s.DB.Create(cfg).Error
|
||||
}
|
||||
|
||||
// DeleteDeployConfig removes the deploy config for a project.
|
||||
func (s *Store) DeleteDeployConfig(projectID uint) error {
|
||||
return s.DB.Where("project_id = ?", projectID).Delete(&model.DeployConfig{}).Error
|
||||
}
|
||||
|
||||
// --- DeployRecord CRUD ---
|
||||
|
||||
// CreateDeployRecord creates a new deploy record.
|
||||
func (s *Store) CreateDeployRecord(r *model.DeployRecord) error {
|
||||
return s.DB.Create(r).Error
|
||||
}
|
||||
|
||||
// UpdateDeployRecord updates an existing deploy record.
|
||||
func (s *Store) UpdateDeployRecord(r *model.DeployRecord) error {
|
||||
return s.DB.Save(r).Error
|
||||
}
|
||||
|
||||
// GetDeployRecord returns a single deploy record by ID.
|
||||
func (s *Store) GetDeployRecord(id uint) (*model.DeployRecord, error) {
|
||||
var r model.DeployRecord
|
||||
err := s.DB.Preload("Project").First(&r, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// ListDeployRecords returns deploy history for a project.
|
||||
func (s *Store) ListDeployRecords(projectID uint, limit int) ([]model.DeployRecord, error) {
|
||||
var records []model.DeployRecord
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
err := s.DB.Where("project_id = ?", projectID).
|
||||
Order("started_at desc").
|
||||
Limit(limit).
|
||||
Find(&records).Error
|
||||
return records, err
|
||||
}
|
||||
186
internal/workspace/workspace.go
Normal file
186
internal/workspace/workspace.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package workspace
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Manager handles workspace directories for projects.
|
||||
type Manager struct {
|
||||
BaseDir string
|
||||
}
|
||||
|
||||
// New creates a new workspace Manager.
|
||||
func New(baseDir string) (*Manager, error) {
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Manager{BaseDir: baseDir}, nil
|
||||
}
|
||||
|
||||
// ProjectDir returns the root workspace directory for a project.
|
||||
func (m *Manager) ProjectDir(projectID uint) string {
|
||||
return filepath.Join(m.BaseDir, fmt.Sprintf("project-%d", projectID))
|
||||
}
|
||||
|
||||
// SrcDir returns the source directory for a project.
|
||||
func (m *Manager) SrcDir(projectID uint) string {
|
||||
return filepath.Join(m.ProjectDir(projectID), "src")
|
||||
}
|
||||
|
||||
// BuildsDir returns the builds directory for a project.
|
||||
func (m *Manager) BuildsDir(projectID uint) string {
|
||||
return filepath.Join(m.ProjectDir(projectID), "builds")
|
||||
}
|
||||
|
||||
// LogsDir returns the logs directory for a project.
|
||||
func (m *Manager) LogsDir(projectID uint) string {
|
||||
return filepath.Join(m.ProjectDir(projectID), "logs")
|
||||
}
|
||||
|
||||
// InitProject creates all required directories for a project.
|
||||
func (m *Manager) InitProject(projectID uint) error {
|
||||
dirs := []string{
|
||||
m.SrcDir(projectID),
|
||||
m.BuildsDir(projectID),
|
||||
m.LogsDir(projectID),
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if err := os.MkdirAll(d, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanProject removes the entire project workspace.
|
||||
func (m *Manager) CleanProject(projectID uint) error {
|
||||
return os.RemoveAll(m.ProjectDir(projectID))
|
||||
}
|
||||
|
||||
// ExtractArchive detects archive type and extracts to the project src directory.
|
||||
// Supports .zip, .tar.gz, .tgz.
|
||||
func (m *Manager) ExtractArchive(projectID uint, filePath string) error {
|
||||
dest := m.SrcDir(projectID)
|
||||
// Clean destination first
|
||||
os.RemoveAll(dest)
|
||||
os.MkdirAll(dest, 0755)
|
||||
|
||||
lower := strings.ToLower(filePath)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".zip"):
|
||||
return extractZip(filePath, dest)
|
||||
case strings.HasSuffix(lower, ".tar.gz"), strings.HasSuffix(lower, ".tgz"):
|
||||
return extractTarGz(filePath, dest)
|
||||
default:
|
||||
return fmt.Errorf("unsupported archive format: %s (use .zip or .tar.gz)", filePath)
|
||||
}
|
||||
}
|
||||
|
||||
func extractZip(src, dest string) error {
|
||||
r, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.Close()
|
||||
|
||||
for _, f := range r.File {
|
||||
// Prevent zip slip
|
||||
path := filepath.Join(dest, f.Name)
|
||||
if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("illegal file path in zip: %s", f.Name)
|
||||
}
|
||||
|
||||
if f.FileInfo().IsDir() {
|
||||
os.MkdirAll(path, 0755)
|
||||
continue
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Dir(path), 0755)
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, rc)
|
||||
rc.Close()
|
||||
out.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTarGz(src, dest string) error {
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gzReader, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gzReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(gzReader)
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
path := filepath.Join(dest, header.Name)
|
||||
if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(dest)+string(os.PathSeparator)) {
|
||||
return fmt.Errorf("illegal file path in tar: %s", header.Name)
|
||||
}
|
||||
|
||||
switch header.Typeflag {
|
||||
case tar.TypeDir:
|
||||
os.MkdirAll(path, 0755)
|
||||
case tar.TypeReg:
|
||||
os.MkdirAll(filepath.Dir(path), 0755)
|
||||
out, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(out, tarReader)
|
||||
out.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveUploadedFile saves an uploaded file to a temp location.
|
||||
func (m *Manager) SaveUploadedFile(projectID uint, reader io.Reader, filename string) (string, error) {
|
||||
dir := m.ProjectDir(projectID)
|
||||
os.MkdirAll(dir, 0755)
|
||||
|
||||
path := filepath.Join(dir, filename)
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = io.Copy(f, reader)
|
||||
return path, err
|
||||
}
|
||||
56
main.go
Normal file
56
main.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"ci/internal/server"
|
||||
)
|
||||
|
||||
//go:embed web/dist
|
||||
var frontend embed.FS
|
||||
|
||||
func main() {
|
||||
port := flag.String("port", "8080", "HTTP server port")
|
||||
dataDir := flag.String("data", "data", "Data directory path")
|
||||
flag.Parse()
|
||||
|
||||
// Check if frontend is embedded (non-empty)
|
||||
var embeddedFS *embed.FS
|
||||
if _, err := frontend.ReadDir("web/dist"); err == nil {
|
||||
embeddedFS = &frontend
|
||||
fmt.Println("Using embedded frontend")
|
||||
} else {
|
||||
fmt.Println("Embedded frontend not found, using dev mode (web/dist)")
|
||||
}
|
||||
|
||||
config := server.Config{
|
||||
Port: *port,
|
||||
DataDir: *dataDir,
|
||||
}
|
||||
|
||||
srv, err := server.New(config, embeddedFS)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create server: %v", err)
|
||||
}
|
||||
|
||||
// Graceful shutdown on interrupt
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-quit
|
||||
fmt.Println("\nShutting down...")
|
||||
srv.Shutdown()
|
||||
os.Exit(0)
|
||||
}()
|
||||
|
||||
fmt.Printf("CI/CD Server starting on http://localhost:%s\n", config.Port)
|
||||
if err := srv.Run(); err != nil {
|
||||
log.Fatalf("Server error: %v", err)
|
||||
}
|
||||
}
|
||||
34
server.log
Normal file
34
server.log
Normal file
@@ -0,0 +1,34 @@
|
||||
Using embedded frontend
|
||||
CI/CD Server starting on http://localhost:8080
|
||||
[GIN] 2026/06/18 - 16:29:13 | 200 | 0s | ::1 | GET "/api/health"
|
||||
[GIN] 2026/06/18 - 16:29:17 | 200 | 52.91ms | ::1 | GET "/"
|
||||
[GIN] 2026/06/18 - 16:29:17 | 200 | 3.18ms | ::1 | GET "/assets/index-BJ3d5HNU.js"
|
||||
[GIN] 2026/06/18 - 16:29:17 | 200 | 3.69ms | ::1 | GET "/assets/index-DHo9r-sY.css"
|
||||
[GIN] 2026/06/18 - 16:29:17 | 200 | 1.65ms | ::1 | GET "/assets/index-BJ3d5HNU.js"
|
||||
[GIN] 2026/06/18 - 16:29:17 | 200 | 505.7µs | ::1 | GET "/api/projects"
|
||||
[GIN] 2026/06/18 - 16:29:34 | 200 | 0s | ::1 | GET "/"
|
||||
[GIN] 2026/06/18 - 16:29:34 | 200 | 2.01ms | ::1 | GET "/assets/index-DHo9r-sY.css"
|
||||
[GIN] 2026/06/18 - 16:29:34 | 200 | 4.44ms | ::1 | GET "/assets/index-BJ3d5HNU.js"
|
||||
[GIN] 2026/06/18 - 16:29:34 | 200 | 3.18ms | ::1 | GET "/assets/index-BJ3d5HNU.js"
|
||||
[GIN] 2026/06/18 - 16:29:34 | 200 | 0s | ::1 | GET "/api/projects"
|
||||
[GIN] 2026/06/18 - 16:29:34 | 200 | 0s | ::1 | GET "/favicon.ico"
|
||||
[GIN] 2026/06/18 - 16:54:28 | 200 | 0s | ::1 | GET "/"
|
||||
[GIN] 2026/06/18 - 16:54:28 | 200 | 1.91ms | ::1 | GET "/assets/index-DHo9r-sY.css"
|
||||
[GIN] 2026/06/18 - 16:54:28 | 200 | 4.1ms | ::1 | GET "/assets/index-BJ3d5HNU.js"
|
||||
[GIN] 2026/06/18 - 16:54:28 | 200 | 2.04ms | ::1 | GET "/assets/index-BJ3d5HNU.js"
|
||||
[GIN] 2026/06/18 - 16:54:28 | 200 | 0s | ::1 | GET "/api/projects"
|
||||
[GIN] 2026/06/18 - 16:54:28 | 200 | 0s | ::1 | GET "/favicon.ico"
|
||||
[GIN] 2026/06/18 - 19:40:22 | 200 | 0s | ::1 | GET "/api/health"
|
||||
[GIN] 2026/06/18 - 19:40:48 | 200 | 0s | ::1 | GET "/api/health"
|
||||
[GIN] 2026/06/18 - 19:41:12 | 200 | 0s | ::1 | GET "/api/health"
|
||||
[GIN] 2026/06/18 - 19:41:50 | 200 | 0s | ::1 | GET "/api/health"
|
||||
[GIN] 2026/06/18 - 19:41:56 | 404 | 9.01ms | ::1 | GET "/api/v1/public/company"
|
||||
[GIN] 2026/06/18 - 19:41:56 | 404 | 0s | ::1 | GET "/api/v1/public/sites"
|
||||
[GIN] 2026/06/18 - 19:41:56 | 404 | 0s | ::1 | GET "/api/v1/public/nav"
|
||||
[GIN] 2026/06/18 - 19:41:56 | 404 | 0s | ::1 | POST "/api/v1/auth/send-sms"
|
||||
[GIN] 2026/06/18 - 19:42:33 | 200 | 542.2µs | ::1 | GET "/api/health"
|
||||
[GIN] 2026/06/18 - 19:42:36 | 404 | 0s | ::1 | GET "/api/v1/public/company"
|
||||
[GIN] 2026/06/18 - 19:42:36 | 404 | 0s | ::1 | GET "/api/v1/public/sites"
|
||||
[GIN] 2026/06/18 - 19:42:36 | 404 | 0s | ::1 | POST "/api/v1/auth/send-sms"
|
||||
[GIN] 2026/06/18 - 19:42:42 | 200 | 0s | ::1 | GET "/api/health"
|
||||
[GIN] 2026/06/18 - 19:43:21 | 404 | 1.83ms | ::1 | GET "/api/v1/public/company"
|
||||
40
web/.gitignore
vendored
Normal file
40
web/.gitignore
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist/*
|
||||
!dist/.gitkeep
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
38
web/README.md
Normal file
38
web/README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# web
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
0
web/dist/.gitkeep
vendored
Normal file
0
web/dist/.gitkeep
vendored
Normal file
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>CI Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
8
web/jsconfig.json
Normal file
8
web/jsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
2836
web/package-lock.json
generated
Normal file
2836
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
25
web/package.json
Normal file
25
web/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && touch dist/.gitkeep",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.17.0",
|
||||
"element-plus": "^2.14.2",
|
||||
"vue": "^3.5.32",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"vite": "^8.0.8",
|
||||
"vite-plugin-vue-devtools": "^8.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
}
|
||||
BIN
web/public/favicon.ico
Normal file
BIN
web/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
179
web/src/App.vue
Normal file
179
web/src/App.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<script setup>
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-brand" @click="router.push('/')">
|
||||
<div class="brand-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="brand-text">CI Hub</span>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<router-link to="/" class="nav-item" :class="{ active: route.path === '/' }">
|
||||
<span class="nav-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
||||
</span>
|
||||
<span class="nav-label">Projects</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="btn-new-project" @click="router.push('/projects/new')">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
*,
|
||||
*::before,
|
||||
*::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
|
||||
/* Page transition */
|
||||
.page-enter-active,
|
||||
.page-leave-active { transition: opacity 0.2s ease, transform 0.2s ease; }
|
||||
.page-enter-from { opacity: 0; transform: translateY(8px); }
|
||||
.page-leave-to { opacity: 0; transform: translateY(-8px); }
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ── Sidebar ── */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(180deg, #1a1d2e 0%, #16192a 100%);
|
||||
color: #c8ccd8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
user-select: none;
|
||||
border-right: 1px solid #ffffff08;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 22px 20px 18px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #ffffff0a;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 36px; height: 36px;
|
||||
background: linear-gradient(135deg, #409eff, #337ecc);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
.brand-icon svg { width: 20px; height: 20px; }
|
||||
|
||||
.brand-text {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #e8eaef;
|
||||
letter-spacing: -0.3px;
|
||||
}
|
||||
|
||||
/* ── Nav ── */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 12px 10px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 8px;
|
||||
color: #9ca0b0;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s ease;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: #ffffff08;
|
||||
color: #d0d4e0;
|
||||
}
|
||||
.nav-item.active {
|
||||
background: #ffffff0f;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-icon svg { width: 18px; height: 18px; display: block; }
|
||||
|
||||
/* ── Footer button ── */
|
||||
.sidebar-footer {
|
||||
padding: 16px 14px 20px;
|
||||
border-top: 1px solid #ffffff0a;
|
||||
}
|
||||
|
||||
.btn-new-project {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
background: linear-gradient(135deg, #409eff, #337ecc);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.btn-new-project:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 14px #409eff40;
|
||||
}
|
||||
.btn-new-project:active { transform: translateY(0); }
|
||||
.btn-new-project svg { width: 16px; height: 16px; }
|
||||
|
||||
/* ── Main ── */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 28px 32px;
|
||||
background: #f0f2f5;
|
||||
}
|
||||
</style>
|
||||
90
web/src/api/index.js
Normal file
90
web/src/api/index.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
// --- Projects ---
|
||||
export function listProjects() {
|
||||
return api.get('/projects').then(r => r.data)
|
||||
}
|
||||
|
||||
export function getProject(id) {
|
||||
return api.get(`/projects/${id}`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function createProject(data) {
|
||||
return api.post('/projects', data).then(r => r.data)
|
||||
}
|
||||
|
||||
export function updateProject(id, data) {
|
||||
return api.put(`/projects/${id}`, data).then(r => r.data)
|
||||
}
|
||||
|
||||
export function deleteProject(id) {
|
||||
return api.delete(`/projects/${id}`).then(r => r.data)
|
||||
}
|
||||
|
||||
// --- Build & Run ---
|
||||
export function triggerBuild(id) {
|
||||
return api.post(`/projects/${id}/build`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function startProject(id) {
|
||||
return api.post(`/projects/${id}/start`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function stopProject(id) {
|
||||
return api.post(`/projects/${id}/stop`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function restartProject(id) {
|
||||
return api.post(`/projects/${id}/restart`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function getBuilds(id) {
|
||||
return api.get(`/projects/${id}/builds`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function getBuildLog(id, buildId) {
|
||||
return api.get(`/projects/${id}/logs/${buildId}`).then(r => r.data)
|
||||
}
|
||||
|
||||
// --- Upload ---
|
||||
export function uploadFile(id, formData) {
|
||||
return api.post(`/projects/${id}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}).then(r => r.data)
|
||||
}
|
||||
|
||||
// --- Deploy Config ---
|
||||
export function getDeployConfig(id) {
|
||||
return api.get(`/projects/${id}/deploy-config`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function updateDeployConfig(id, data) {
|
||||
return api.put(`/projects/${id}/deploy-config`, data).then(r => r.data)
|
||||
}
|
||||
|
||||
export function deleteDeployConfig(id) {
|
||||
return api.delete(`/projects/${id}/deploy-config`).then(r => r.data)
|
||||
}
|
||||
|
||||
// --- Deploy Execution ---
|
||||
export function triggerDeploy(id) {
|
||||
return api.post(`/projects/${id}/deploy`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function getDeployRecords(id) {
|
||||
return api.get(`/projects/${id}/deploy-records`).then(r => r.data)
|
||||
}
|
||||
|
||||
export function getDeployLog(id, deployId) {
|
||||
return api.get(`/projects/${id}/deploy-records/${deployId}/log`).then(r => r.data)
|
||||
}
|
||||
|
||||
// --- SSE ---
|
||||
export function createLogStream(id) {
|
||||
return new EventSource(`/api/projects/${id}/logs/stream`)
|
||||
}
|
||||
86
web/src/assets/base.css
Normal file
86
web/src/assets/base.css
Normal file
@@ -0,0 +1,86 @@
|
||||
/* color palette from <https://github.com/vuejs/theme> */
|
||||
:root {
|
||||
--vt-c-white: #ffffff;
|
||||
--vt-c-white-soft: #f8f8f8;
|
||||
--vt-c-white-mute: #f2f2f2;
|
||||
|
||||
--vt-c-black: #181818;
|
||||
--vt-c-black-soft: #222222;
|
||||
--vt-c-black-mute: #282828;
|
||||
|
||||
--vt-c-indigo: #2c3e50;
|
||||
|
||||
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
|
||||
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
|
||||
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
|
||||
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
|
||||
|
||||
--vt-c-text-light-1: var(--vt-c-indigo);
|
||||
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
|
||||
--vt-c-text-dark-1: var(--vt-c-white);
|
||||
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
|
||||
}
|
||||
|
||||
/* semantic color variables for this project */
|
||||
:root {
|
||||
--color-background: var(--vt-c-white);
|
||||
--color-background-soft: var(--vt-c-white-soft);
|
||||
--color-background-mute: var(--vt-c-white-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-light-2);
|
||||
--color-border-hover: var(--vt-c-divider-light-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-light-1);
|
||||
--color-text: var(--vt-c-text-light-1);
|
||||
|
||||
--section-gap: 160px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-background: var(--vt-c-black);
|
||||
--color-background-soft: var(--vt-c-black-soft);
|
||||
--color-background-mute: var(--vt-c-black-mute);
|
||||
|
||||
--color-border: var(--vt-c-divider-dark-2);
|
||||
--color-border-hover: var(--vt-c-divider-dark-1);
|
||||
|
||||
--color-heading: var(--vt-c-text-dark-1);
|
||||
--color-text: var(--vt-c-text-dark-2);
|
||||
}
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
color: var(--color-text);
|
||||
background: var(--color-background);
|
||||
transition:
|
||||
color 0.5s,
|
||||
background-color 0.5s;
|
||||
line-height: 1.6;
|
||||
font-family:
|
||||
Inter,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Fira Sans',
|
||||
'Droid Sans',
|
||||
'Helvetica Neue',
|
||||
sans-serif;
|
||||
font-size: 15px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
1
web/src/assets/logo.svg
Normal file
1
web/src/assets/logo.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
35
web/src/assets/main.css
Normal file
35
web/src/assets/main.css
Normal file
@@ -0,0 +1,35 @@
|
||||
@import './base.css';
|
||||
|
||||
#app {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
a,
|
||||
.green {
|
||||
text-decoration: none;
|
||||
color: hsla(160, 100%, 37%, 1);
|
||||
transition: 0.4s;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
a:hover {
|
||||
background-color: hsla(160, 100%, 37%, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
body {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
}
|
||||
44
web/src/components/HelloWorld.vue
Normal file
44
web/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
msg: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="greetings">
|
||||
<h1 class="green">{{ msg }}</h1>
|
||||
<h3>
|
||||
You’ve successfully created a project with
|
||||
<a href="https://vite.dev/" target="_blank" rel="noopener">Vite</a> +
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">Vue 3</a>.
|
||||
</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
h1 {
|
||||
font-weight: 500;
|
||||
font-size: 2.6rem;
|
||||
position: relative;
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.greetings h1,
|
||||
.greetings h3 {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
95
web/src/components/TheWelcome.vue
Normal file
95
web/src/components/TheWelcome.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup>
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener"
|
||||
>Vue - Official</a
|
||||
>. If you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
87
web/src/components/WelcomeItem.vue
Normal file
87
web/src/components/WelcomeItem.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
7
web/src/components/icons/IconCommunity.vue
Normal file
7
web/src/components/icons/IconCommunity.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
web/src/components/icons/IconDocumentation.vue
Normal file
7
web/src/components/icons/IconDocumentation.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
web/src/components/icons/IconEcosystem.vue
Normal file
7
web/src/components/icons/IconEcosystem.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
7
web/src/components/icons/IconSupport.vue
Normal file
7
web/src/components/icons/IconSupport.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
19
web/src/components/icons/IconTooling.vue
Normal file
19
web/src/components/icons/IconTooling.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
10
web/src/main.js
Normal file
10
web/src/main.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
17
web/src/router/index.js
Normal file
17
web/src/router/index.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
import ProjectCreate from '../views/ProjectCreate.vue'
|
||||
import ProjectDetail from '../views/ProjectDetail.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'Dashboard', component: Dashboard },
|
||||
{ path: '/projects/new', name: 'ProjectCreate', component: ProjectCreate },
|
||||
{ path: '/projects/:id', name: 'ProjectDetail', component: ProjectDetail, props: true },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
250
web/src/views/Dashboard.vue
Normal file
250
web/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { listProjects, deleteProject } from '../api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const projects = ref([])
|
||||
const loading = ref(true)
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
projects.value = await listProjects()
|
||||
} catch (e) {
|
||||
ElMessage.error('Failed to load projects')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (project) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`Delete project "${project.name}"? This cannot be undone.`,
|
||||
'Confirm Delete',
|
||||
{ type: 'warning', confirmButtonText: 'Delete', cancelButtonText: 'Cancel' }
|
||||
)
|
||||
await deleteProject(project.id)
|
||||
ElMessage.success('Project deleted')
|
||||
fetchProjects()
|
||||
} catch (e) { /* cancelled */ }
|
||||
}
|
||||
|
||||
const statusTag = (status) => {
|
||||
const map = { running: 'success', stopped: 'info', building: 'warning', error: 'danger' }
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const statusLabel = (status) => {
|
||||
const map = { running: 'Running', stopped: 'Stopped', building: 'Building...', error: 'Error' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
onMounted(fetchProjects)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<header class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Projects</h1>
|
||||
<p class="page-subtitle">{{ projects.length }} project{{ projects.length !== 1 ? 's' : '' }} managed</p>
|
||||
</div>
|
||||
<el-button type="primary" size="large" @click="router.push('/projects/new')" round>
|
||||
<span style="font-size:18px;line-height:1">+</span> New Project
|
||||
</el-button>
|
||||
</header>
|
||||
|
||||
<el-skeleton :loading="loading" :count="3" animated />
|
||||
|
||||
<div v-if="!loading && projects.length === 0" class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
|
||||
</div>
|
||||
<h3>No projects yet</h3>
|
||||
<p>Create your first project to start building and deploying.</p>
|
||||
<el-button type="primary" @click="router.push('/projects/new')">Create Project</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && projects.length > 0" class="project-grid">
|
||||
<article
|
||||
v-for="p in projects"
|
||||
:key="p.id"
|
||||
class="project-card"
|
||||
@click="router.push(`/projects/${p.id}`)"
|
||||
>
|
||||
<div class="card-top">
|
||||
<div class="card-status-row">
|
||||
<span class="status-dot" :class="'dot-' + p.status"></span>
|
||||
<span class="status-text">{{ statusLabel(p.status) }}</span>
|
||||
</div>
|
||||
<el-tag size="small" :type="p.mode === 'git' ? '' : 'warning'" effect="plain" round>
|
||||
{{ p.mode === 'git' ? 'Git' : 'Upload' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<h3 class="card-title">{{ p.name }}</h3>
|
||||
<p class="card-desc">{{ p.description || 'No description' }}</p>
|
||||
|
||||
<div class="card-meta">
|
||||
<span v-if="p.git_url" class="meta-item" :title="p.git_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
{{ p.git_url }}
|
||||
</span>
|
||||
<span v-if="p.port" class="meta-item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
:{{ p.port }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-actions" @click.stop>
|
||||
<el-button size="small" round @click="router.push(`/projects/${p.id}`)">Detail</el-button>
|
||||
<el-button size="small" type="danger" round plain @click="handleDelete(p)">Delete</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard { max-width: 1200px; }
|
||||
|
||||
/* ── Header ── */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1a1d2e;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: #8c8f9a;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ── Empty ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
.empty-icon {
|
||||
width: 72px; height: 72px;
|
||||
margin: 0 auto 20px;
|
||||
background: #e8ecf1;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #a0a4b0;
|
||||
}
|
||||
.empty-icon svg { width: 32px; height: 32px; }
|
||||
.empty-state h3 { font-size: 18px; color: #333; margin-bottom: 6px; }
|
||||
.empty-state p { font-size: 14px; color: #999; margin-bottom: 20px; }
|
||||
|
||||
/* ── Grid ── */
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ── Card ── */
|
||||
.project-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 22px 24px 18px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ebeef2;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 1px 3px #00000005;
|
||||
}
|
||||
.project-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 8px 25px #0000000d;
|
||||
border-color: #d0d7e2;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.card-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px; height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-running { background: #67c23a; box-shadow: 0 0 6px #67c23a60; }
|
||||
.dot-stopped { background: #909399; }
|
||||
.dot-building { background: #e6a23c; animation: pulse 1.2s ease-in-out infinite; }
|
||||
.dot-error { background: #f56c6c; }
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
color: #1a1d2e;
|
||||
margin-bottom: 6px;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: #8c8f9a;
|
||||
min-height: 36px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
.meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: #a0a4b0;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta-item svg { width: 13px; height: 13px; flex-shrink: 0; }
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
</style>
|
||||
219
web/src/views/ProjectCreate.vue
Normal file
219
web/src/views/ProjectCreate.vue
Normal file
@@ -0,0 +1,219 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { createProject } from '../api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
description: '',
|
||||
mode: 'git',
|
||||
git_url: '',
|
||||
git_branch: 'master',
|
||||
git_username: '',
|
||||
git_password: '',
|
||||
build_script: '',
|
||||
run_command: '',
|
||||
output_binary: '',
|
||||
env_vars: '',
|
||||
port: null,
|
||||
})
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.value.name) {
|
||||
ElMessage.warning('Project name is required')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createProject(form.value)
|
||||
ElMessage.success('Project created')
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to create project')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="create-page">
|
||||
<el-page-header @back="router.push('/')" class="page-back">
|
||||
<template #content>
|
||||
<span class="back-title">New Project</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<el-card class="form-card" shadow="never">
|
||||
<el-form :model="form" label-position="top" class="project-form">
|
||||
<!-- Basic info -->
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||
Basic Information
|
||||
</h3>
|
||||
<div class="form-row two-col">
|
||||
<el-form-item label="Project Name" required>
|
||||
<el-input v-model="form.name" placeholder="my-project" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Port">
|
||||
<el-input-number v-model="form.port" :min="1" :max="65535" placeholder="8080" size="large" style="width:100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="Description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="A short description of this project..." />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
Source Code
|
||||
</h3>
|
||||
<el-form-item label="Source Mode">
|
||||
<el-radio-group v-model="form.mode" class="mode-switch">
|
||||
<el-radio-button value="git">
|
||||
<span class="radio-inner">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><path d="M18 9v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9"/><path d="M12 12v3"/></svg>
|
||||
Git Repository
|
||||
</span>
|
||||
</el-radio-button>
|
||||
<el-radio-button value="upload">
|
||||
<span class="radio-inner">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
File Upload
|
||||
</span>
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="form.mode === 'git'">
|
||||
<el-form-item label="Git URL">
|
||||
<el-input v-model="form.git_url" placeholder="https://gitee.com/user/repo.git" size="large" />
|
||||
</el-form-item>
|
||||
<div class="form-row two-col">
|
||||
<el-form-item label="Branch">
|
||||
<el-input v-model="form.git_branch" placeholder="master" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Username (private repos)">
|
||||
<el-input v-model="form.git_username" placeholder="optional" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="Password / Token">
|
||||
<el-input v-model="form.git_password" type="password" placeholder="optional" show-password />
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="form.mode === 'upload'">
|
||||
<el-alert type="info" :closable="false" show-icon>
|
||||
After creating this project, go to its detail page to upload your source files as .zip or .tar.gz.
|
||||
</el-alert>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Build & Run -->
|
||||
<div class="form-section">
|
||||
<h3 class="section-title">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" y1="19" x2="19" y2="13"/><line x1="16" y1="16" x2="20" y2="20"/><line x1="19" y1="21" x2="21" y2="19"/></svg>
|
||||
Build & Run Configuration
|
||||
</h3>
|
||||
<el-form-item label="Build Script">
|
||||
<el-input v-model="form.build_script" type="textarea" :rows="3"
|
||||
placeholder="go build -o app ." />
|
||||
</el-form-item>
|
||||
<div class="form-row two-col">
|
||||
<el-form-item label="Output Binary">
|
||||
<el-input v-model="form.output_binary" placeholder="app" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Run Command">
|
||||
<el-input v-model="form.run_command" placeholder="./app" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="Environment Variables">
|
||||
<el-input v-model="form.env_vars" type="textarea" :rows="2"
|
||||
placeholder='{"DB_HOST": "192.168.1.100", "DB_PORT": "3306"}' />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button size="large" @click="router.push('/')">Cancel</el-button>
|
||||
<el-button type="primary" size="large" :loading="submitting" @click="handleSubmit">
|
||||
Create Project
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.create-page { max-width: 760px; margin: 0 auto; }
|
||||
|
||||
.page-back { margin-bottom: 20px; }
|
||||
.back-title { font-size: 20px; font-weight: 700; color: #1a1d2e; }
|
||||
|
||||
.form-card {
|
||||
border-radius: 14px;
|
||||
border: 1px solid #ebeef2;
|
||||
box-shadow: 0 1px 3px #00000005;
|
||||
}
|
||||
.form-card :deep(.el-card__body) { padding: 28px 32px; }
|
||||
|
||||
/* ── Sections ── */
|
||||
.form-section {
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.form-section:last-of-type { border-bottom: none; margin-bottom: 24px; padding-bottom: 0; }
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1a1d2e;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.section-title svg { width: 18px; height: 18px; color: #409eff; }
|
||||
|
||||
.form-row { display: flex; gap: 16px; }
|
||||
.form-row.two-col > * { flex: 1; }
|
||||
|
||||
/* ── Mode switch ── */
|
||||
.mode-switch { width: 100%; }
|
||||
.mode-switch :deep(.el-radio-button) { flex: 1; }
|
||||
.mode-switch :deep(.el-radio-button__inner) {
|
||||
width: 100%;
|
||||
padding: 14px 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.radio-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.radio-inner svg { width: 16px; height: 16px; }
|
||||
|
||||
/* ── Form items ── */
|
||||
.project-form :deep(.el-form-item__label) {
|
||||
font-weight: 600;
|
||||
color: #444;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
</style>
|
||||
687
web/src/views/ProjectDetail.vue
Normal file
687
web/src/views/ProjectDetail.vue
Normal file
@@ -0,0 +1,687 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
getProject, updateProject, triggerBuild, startProject, stopProject, restartProject,
|
||||
getBuilds, uploadFile, createLogStream,
|
||||
getDeployConfig, updateDeployConfig, deleteDeployConfig, triggerDeploy, getDeployRecords, getDeployLog
|
||||
} from '../api'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps({ id: String })
|
||||
const router = useRouter()
|
||||
|
||||
const project = ref(null)
|
||||
const builds = ref([])
|
||||
const logs = ref([])
|
||||
const logContainer = ref(null)
|
||||
const activeTab = ref('overview')
|
||||
const loading = ref(true)
|
||||
const actionLoading = ref(false)
|
||||
const editingSettings = ref(false)
|
||||
const settingsForm = ref({})
|
||||
const uploadRef = ref(null)
|
||||
|
||||
const deployConfig = ref(null)
|
||||
const deployRecords = ref([])
|
||||
const editingDeploy = ref(false)
|
||||
const deployForm = ref({
|
||||
host: '', port: 22, username: '', auth_method: 'key',
|
||||
ssh_key: '', ssh_password: '',
|
||||
deploy_dir: '', file_mappings: '',
|
||||
pre_deploy_command: '', post_deploy_command: '',
|
||||
health_check_url: '', health_check_timeout: 30,
|
||||
auto_deploy: false,
|
||||
})
|
||||
|
||||
let sse = null
|
||||
|
||||
const projectId = () => parseInt(props.id)
|
||||
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
project.value = await getProject(projectId())
|
||||
} catch (e) {
|
||||
ElMessage.error('Project not found')
|
||||
router.push('/')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchBuilds = async () => {
|
||||
try { builds.value = await getBuilds(projectId()) } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
const connectLogStream = () => {
|
||||
if (sse) sse.close()
|
||||
sse = createLogStream(projectId())
|
||||
sse.onmessage = (event) => {
|
||||
logs.value.push(event.data)
|
||||
if (logs.value.length > 1000) logs.value.shift()
|
||||
setTimeout(() => {
|
||||
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
}, 50)
|
||||
}
|
||||
sse.onerror = () => {}
|
||||
}
|
||||
|
||||
const doBuild = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await triggerBuild(projectId())
|
||||
ElMessage.success('Build started')
|
||||
logs.value = []
|
||||
activeTab.value = 'logs'
|
||||
connectLogStream()
|
||||
fetchDeployRecords()
|
||||
pollStatus(30000)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Build failed')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const doStart = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await startProject(projectId())
|
||||
ElMessage.success('Project started')
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to start')
|
||||
} finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const doStop = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await stopProject(projectId())
|
||||
ElMessage.success('Project stopped')
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to stop')
|
||||
} finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const doRestart = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await restartProject(projectId())
|
||||
ElMessage.success('Project restarted')
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to restart')
|
||||
} finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const doUpload = async (file) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file.file)
|
||||
try {
|
||||
await uploadFile(projectId(), formData)
|
||||
ElMessage.success('File uploaded and extracted')
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Upload failed')
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
await updateProject(projectId(), settingsForm.value)
|
||||
ElMessage.success('Settings saved')
|
||||
editingSettings.value = false
|
||||
fetchProject()
|
||||
} catch (e) {
|
||||
ElMessage.error('Failed to save settings')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDeployConfig = async () => {
|
||||
try {
|
||||
const cfg = await getDeployConfig(projectId())
|
||||
if (cfg && cfg.id) deployConfig.value = cfg
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const fetchDeployRecords = async () => {
|
||||
try { deployRecords.value = await getDeployRecords(projectId()) } catch (e) {}
|
||||
}
|
||||
|
||||
const saveDeployConfig = async () => {
|
||||
try {
|
||||
await updateDeployConfig(projectId(), deployForm.value)
|
||||
ElMessage.success('Deploy config saved')
|
||||
editingDeploy.value = false
|
||||
fetchDeployConfig()
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to save deploy config')
|
||||
}
|
||||
}
|
||||
|
||||
const removeDeployConfig = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm('Remove deploy configuration?', 'Confirm', { type: 'warning' })
|
||||
await deleteDeployConfig(projectId())
|
||||
ElMessage.success('Deploy config removed')
|
||||
deployConfig.value = null
|
||||
deployRecords.value = []
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
const doDeploy = async () => {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await triggerDeploy(projectId())
|
||||
ElMessage.success('Deployment started')
|
||||
logs.value = []
|
||||
activeTab.value = 'logs'
|
||||
connectLogStream()
|
||||
setTimeout(() => { fetchProject(); fetchDeployRecords() }, 3000)
|
||||
} catch (e) {
|
||||
ElMessage.error(e.response?.data?.error || 'Failed to start deploy')
|
||||
} finally { actionLoading.value = false }
|
||||
}
|
||||
|
||||
const viewDeployLog = async (deployId) => {
|
||||
try {
|
||||
const log = await getDeployLog(projectId(), deployId)
|
||||
logs.value = log.split('\n')
|
||||
activeTab.value = 'logs'
|
||||
setTimeout(() => {
|
||||
if (logContainer.value) logContainer.value.scrollTop = logContainer.value.scrollHeight
|
||||
}, 100)
|
||||
} catch (e) {
|
||||
ElMessage.error('Failed to load deploy log')
|
||||
}
|
||||
}
|
||||
|
||||
const deployStatusTag = (s) => {
|
||||
const map = { running: 'warning', success: 'success', failed: 'danger' }
|
||||
return map[s] || 'info'
|
||||
}
|
||||
|
||||
const pollStatus = (duration) => {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const p = await getProject(projectId())
|
||||
project.value = p
|
||||
if (p.status !== 'building') {
|
||||
clearInterval(interval)
|
||||
fetchBuilds()
|
||||
}
|
||||
} catch (e) { clearInterval(interval) }
|
||||
}, 2000)
|
||||
setTimeout(() => clearInterval(interval), duration)
|
||||
}
|
||||
|
||||
const statusTag = (s) => {
|
||||
const map = { running: 'success', stopped: 'info', building: 'warning', error: 'danger' }
|
||||
return map[s] || 'info'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchProject()
|
||||
fetchBuilds()
|
||||
fetchDeployConfig()
|
||||
fetchDeployRecords()
|
||||
connectLogStream()
|
||||
})
|
||||
|
||||
onUnmounted(() => { if (sse) sse.close() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="loading" class="detail-page">
|
||||
<el-page-header @back="router.push('/')" class="page-back">
|
||||
<template #content>
|
||||
<span v-if="project" class="back-title">{{ project.name }}</span>
|
||||
</template>
|
||||
</el-page-header>
|
||||
|
||||
<div v-if="project">
|
||||
<!-- ==== Action Bar ==== -->
|
||||
<div class="action-bar">
|
||||
<div class="action-bar-left">
|
||||
<span class="status-badge" :class="'badge-' + project.status">
|
||||
<span class="badge-dot"></span>
|
||||
{{ project.status }}
|
||||
</span>
|
||||
<span class="mode-badge">{{ project.mode === 'git' ? 'Git' : 'Upload' }}</span>
|
||||
</div>
|
||||
<div class="action-bar-right">
|
||||
<el-button-group class="btn-group">
|
||||
<el-button type="primary" :loading="actionLoading" @click="doBuild" :disabled="project.status === 'building'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon"><polyline points="14.5 17.5 3 6 3 3 6 3 17.5 14.5"/><line x1="13" y1="19" x2="19" y2="13"/><line x1="16" y1="16" x2="20" y2="20"/><line x1="19" y1="21" x2="21" y2="19"/></svg>
|
||||
Build
|
||||
</el-button>
|
||||
<el-button type="success" :loading="actionLoading" @click="doStart" :disabled="project.status === 'running'">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none" class="btn-icon"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Start
|
||||
</el-button>
|
||||
<el-button type="warning" :loading="actionLoading" @click="doStop" :disabled="project.status !== 'running'">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none" class="btn-icon"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>
|
||||
Stop
|
||||
</el-button>
|
||||
<el-button :loading="actionLoading" @click="doRestart" :disabled="project.status !== 'running'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
|
||||
Restart
|
||||
</el-button>
|
||||
</el-button-group>
|
||||
<el-button
|
||||
v-if="deployConfig"
|
||||
type="primary"
|
||||
:loading="actionLoading"
|
||||
@click="doDeploy"
|
||||
class="btn-deploy"
|
||||
plain
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="btn-icon"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
Deploy
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==== Tabs ==== -->
|
||||
<el-tabs v-model="activeTab" class="main-tabs">
|
||||
<!-- Overview -->
|
||||
<el-tab-pane label="Overview" name="overview">
|
||||
<div class="overview-grid">
|
||||
<el-card class="ov-card" shadow="never">
|
||||
<template #header><span class="card-header-title">Project Info</span></template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="Status">
|
||||
<el-tag :type="statusTag(project.status)" effect="dark" size="small">{{ project.status }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Mode">{{ project.mode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="PID">{{ project.pid || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Port">{{ project.port || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Created">{{ new Date(project.created_at).toLocaleString() }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
<el-card class="ov-card" shadow="never" v-if="project.mode === 'git'">
|
||||
<template #header><span class="card-header-title">Git Repository</span></template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="URL">{{ project.git_url || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Branch">{{ project.git_branch || 'master' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
<el-card class="ov-card" shadow="never">
|
||||
<template #header><span class="card-header-title">Build Config</span></template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="Build Script">{{ project.build_script || 'go build -o app .' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Output Binary">{{ project.output_binary || 'app' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Run Command">{{ project.run_command || './app' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-card v-if="project.mode === 'upload'" shadow="never" class="upload-card">
|
||||
<template #header><span class="card-header-title">Upload Source Files</span></template>
|
||||
<el-upload ref="uploadRef" drag :auto-upload="false" :on-change="doUpload" :limit="1" accept=".zip,.tar.gz,.tgz">
|
||||
<div class="upload-area">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="upload-icon"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
<p>Drop .zip or .tar.gz here or click to upload</p>
|
||||
</div>
|
||||
</el-upload>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Logs -->
|
||||
<el-tab-pane label="Logs" name="logs">
|
||||
<div class="log-console" ref="logContainer">
|
||||
<div v-if="logs.length === 0" class="log-empty">
|
||||
<span class="log-prompt">Waiting for output...</span>
|
||||
</div>
|
||||
<div v-for="(line, i) in logs" :key="i" class="log-line" :class="{ 'log-deploy': line.startsWith('[Deploy]'), 'log-error': line.includes('ERROR') || line.includes('FAILED') || line.includes('failed'), 'log-success': line.includes('SUCCESS') || line.includes('completed successfully') }">{{ line }}</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Build History -->
|
||||
<el-tab-pane label="Build History" name="builds">
|
||||
<el-card shadow="never">
|
||||
<el-table :data="builds" stripe class="history-table">
|
||||
<el-table-column prop="id" label="#" width="60" align="center" />
|
||||
<el-table-column prop="status" label="Status" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'success' ? 'success' : row.status === 'running' ? 'warning' : 'danger'" size="small" effect="dark" round>
|
||||
{{ row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Commit" width="120">
|
||||
<template #default="{ row }">
|
||||
<code v-if="row.commit_hash" class="commit-hash">{{ (row.commit_hash || '').slice(0, 7) }}</code>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Started" width="180">
|
||||
<template #default="{ row }">{{ new Date(row.started_at).toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Duration">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.finished_at">{{ Math.round((new Date(row.finished_at) - new Date(row.started_at)) / 1000) }}s</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Log" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" @click="activeTab = 'logs'">View</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="builds.length === 0" description="No builds yet" :image-size="80" />
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Settings -->
|
||||
<el-tab-pane label="Settings" name="settings">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span class="card-header-title">Project Settings</span>
|
||||
<el-button v-if="!editingSettings" type="primary" plain size="small" round @click="editingSettings = true; settingsForm = { ...project }">Edit</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-form v-if="editingSettings" :model="settingsForm" label-position="top">
|
||||
<el-form-item label="Name"><el-input v-model="settingsForm.name" /></el-form-item>
|
||||
<el-form-item label="Description"><el-input v-model="settingsForm.description" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item v-if="project.mode === 'git'" label="Git URL"><el-input v-model="settingsForm.git_url" /></el-form-item>
|
||||
<el-form-item v-if="project.mode === 'git'" label="Branch"><el-input v-model="settingsForm.git_branch" /></el-form-item>
|
||||
<el-form-item label="Build Script"><el-input v-model="settingsForm.build_script" type="textarea" :rows="3" /></el-form-item>
|
||||
<el-form-item label="Run Command"><el-input v-model="settingsForm.run_command" /></el-form-item>
|
||||
<el-form-item label="Port"><el-input-number v-model="settingsForm.port" /></el-form-item>
|
||||
<el-form-item label="Env Vars"><el-input v-model="settingsForm.env_vars" type="textarea" :rows="2" /></el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveSettings">Save</el-button>
|
||||
<el-button @click="editingSettings = false">Cancel</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-descriptions v-else :column="1" border>
|
||||
<el-descriptions-item label="Name">{{ project.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Description">{{ project.description || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Mode">{{ project.mode }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="project.mode === 'git'" label="Git URL">{{ project.git_url || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Build Script">{{ project.build_script || 'go build -o app .' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Run Command">{{ project.run_command || './app' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Port">{{ project.port || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Deploy -->
|
||||
<el-tab-pane label="Deploy" name="deploy">
|
||||
<el-card class="deploy-config-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header-row">
|
||||
<span class="card-header-title">Deploy Configuration</span>
|
||||
<div class="header-actions">
|
||||
<el-button v-if="!editingDeploy && deployConfig" type="primary" plain size="small" round @click="editingDeploy = true; deployForm = { ...deployConfig, ssh_key: '', ssh_password: '' }">Edit</el-button>
|
||||
<el-button v-if="!editingDeploy && !deployConfig" type="primary" size="small" round @click="editingDeploy = true">Add Config</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form v-if="editingDeploy" :model="deployForm" label-position="top" class="deploy-form">
|
||||
<div class="form-section">
|
||||
<h4 class="section-subtitle">SSH Connection</h4>
|
||||
<div class="form-row three-col">
|
||||
<el-form-item label="Host" required><el-input v-model="deployForm.host" placeholder="192.168.1.100" /></el-form-item>
|
||||
<el-form-item label="Port"><el-input-number v-model="deployForm.port" :min="1" :max="65535" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="Username" required><el-input v-model="deployForm.username" placeholder="root" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="Auth Method">
|
||||
<el-radio-group v-model="deployForm.auth_method">
|
||||
<el-radio value="key">SSH Key</el-radio>
|
||||
<el-radio value="password">Password</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="deployForm.auth_method === 'key'" label="SSH Private Key">
|
||||
<el-input v-model="deployForm.ssh_key" type="textarea" :rows="4" placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="deployForm.auth_method === 'password'" label="SSH Password">
|
||||
<el-input v-model="deployForm.ssh_password" type="password" placeholder="SSH password" show-password />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h4 class="section-subtitle">Deployment</h4>
|
||||
<el-form-item label="Deploy Directory" required><el-input v-model="deployForm.deploy_dir" placeholder="/opt/myapp" /></el-form-item>
|
||||
<el-form-item label="File Mappings">
|
||||
<el-input v-model="deployForm.file_mappings" type="textarea" :rows="3" placeholder='[{"local":"app","remote":"/opt/myapp/app"}]' />
|
||||
</el-form-item>
|
||||
<div class="form-row two-col">
|
||||
<el-form-item label="Pre-deploy Command"><el-input v-model="deployForm.pre_deploy_command" placeholder="systemctl stop myapp" /></el-form-item>
|
||||
<el-form-item label="Post-deploy Command"><el-input v-model="deployForm.post_deploy_command" placeholder="systemctl start myapp" /></el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h4 class="section-subtitle">Health Check</h4>
|
||||
<div class="form-row two-col">
|
||||
<el-form-item label="Health Check URL"><el-input v-model="deployForm.health_check_url" placeholder="http://192.168.1.100:8080/health" /></el-form-item>
|
||||
<el-form-item label="Timeout (seconds)"><el-input-number v-model="deployForm.health_check_timeout" :min="5" :max="120" style="width:100%" /></el-form-item>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h4 class="section-subtitle">Automation</h4>
|
||||
<el-form-item label="Auto-deploy after successful build">
|
||||
<el-switch v-model="deployForm.auto_deploy" active-text="On" inactive-text="Off" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<el-button @click="editingDeploy = false">Cancel</el-button>
|
||||
<el-button v-if="deployConfig" type="danger" plain @click="removeDeployConfig">Remove Config</el-button>
|
||||
<el-button type="primary" @click="saveDeployConfig">Save Configuration</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<el-descriptions v-else-if="deployConfig" :column="1" border>
|
||||
<el-descriptions-item label="SSH">{{ deployConfig.username }}@{{ deployConfig.host }}:{{ deployConfig.port }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Auth">{{ deployConfig.auth_method === 'key' ? 'SSH Key' : 'Password' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Deploy Directory">{{ deployConfig.deploy_dir }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Pre-deploy">{{ deployConfig.pre_deploy_command || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Post-deploy">{{ deployConfig.post_deploy_command || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Health Check">{{ deployConfig.health_check_url || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="Auto-deploy">{{ deployConfig.auto_deploy ? 'Enabled' : 'Disabled' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-empty v-else description="No deploy configuration yet. Click 'Add Config' to set up SSH deployment." :image-size="80" />
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="deploy-history-card">
|
||||
<template #header><span class="card-header-title">Deploy History</span></template>
|
||||
<el-table :data="deployRecords" stripe class="history-table">
|
||||
<el-table-column prop="id" label="#" width="60" align="center" />
|
||||
<el-table-column label="Status" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="deployStatusTag(row.status)" size="small" effect="dark" round>{{ row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Started" width="180">
|
||||
<template #default="{ row }">{{ new Date(row.started_at).toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Duration">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.finished_at">{{ Math.round((new Date(row.finished_at) - new Date(row.started_at)) / 1000) }}s</span>
|
||||
<span v-else class="text-muted">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Build #" width="90" align="center">
|
||||
<template #default="{ row }">{{ row.build_record_id || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Log" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" text type="primary" @click="viewDeployLog(row.id)">View</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="deployRecords.length === 0" description="No deployments yet" :image-size="80" />
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-page { max-width: 1100px; }
|
||||
.page-back { margin-bottom: 16px; }
|
||||
.back-title { font-size: 20px; font-weight: 700; color: #1a1d2e; }
|
||||
|
||||
/* ════ Action Bar ════ */
|
||||
.action-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 18px 22px;
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #ebeef2;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px #00000004;
|
||||
}
|
||||
.action-bar-left { display: flex; align-items: center; gap: 12px; }
|
||||
.action-bar-right { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.badge-dot { width: 7px; height: 7px; border-radius: 50%; }
|
||||
.badge-running { background: #eaf7e2; color: #3d8c1e; }
|
||||
.badge-running .badge-dot { background: #67c23a; }
|
||||
.badge-stopped { background: #f0f1f3; color: #6a6e78; }
|
||||
.badge-stopped .badge-dot { background: #909399; }
|
||||
.badge-building { background: #fef5e7; color: #b07816; }
|
||||
.badge-building .badge-dot { background: #e6a23c; animation: pulse 1.2s ease-in-out infinite; }
|
||||
.badge-error { background: #fef0f0; color: #c03639; }
|
||||
.badge-error .badge-dot { background: #f56c6c; }
|
||||
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }
|
||||
|
||||
.mode-badge {
|
||||
padding: 4px 12px;
|
||||
border-radius: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
background: #e8ecf1;
|
||||
color: #606470;
|
||||
}
|
||||
|
||||
.btn-group .el-button { padding: 8px 16px; }
|
||||
.btn-icon { width: 15px; height: 15px; margin-right: 4px; vertical-align: -2px; }
|
||||
.btn-deploy {
|
||||
border-color: #409eff;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-deploy:hover { background: #409eff10; }
|
||||
|
||||
/* ════ Tabs ════ */
|
||||
.main-tabs :deep(.el-tabs__header) { margin-bottom: 18px; }
|
||||
.main-tabs :deep(.el-tabs__item) {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
/* ════ Overview ════ */
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ov-card { border-radius: 12px; border: 1px solid #ebeef2; }
|
||||
.upload-card { margin-top: 0; border-radius: 12px; border: 1px solid #ebeef2; }
|
||||
.upload-area { padding: 10px 0; text-align: center; color: #909399; }
|
||||
.upload-icon { width: 52px; height: 52px; color: #c0c4cc; margin-bottom: 8px; }
|
||||
|
||||
/* ════ Cards ════ */
|
||||
.card-header-row { display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-header-title { font-size: 15px; font-weight: 700; color: #1a1d2e; }
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ════ Log Console ════ */
|
||||
.log-console {
|
||||
background: #161821;
|
||||
color: #c8d1e0;
|
||||
font-family: 'JetBrains Mono', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 13px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 12px;
|
||||
min-height: 480px;
|
||||
max-height: 560px;
|
||||
overflow-y: auto;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
.log-console::-webkit-scrollbar { width: 6px; }
|
||||
.log-console::-webkit-scrollbar-track { background: transparent; }
|
||||
.log-console::-webkit-scrollbar-thumb { background: #3a3d4a; border-radius: 3px; }
|
||||
|
||||
.log-empty { color: #5a5e6e; padding: 20px 0; text-align: center; }
|
||||
.log-prompt::before { content: '▎'; animation: blink 1s step-end infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
|
||||
.log-deploy { color: #7dcfff; }
|
||||
.log-error { color: #f7768e; font-weight: 500; }
|
||||
.log-success { color: #9ece6a; }
|
||||
|
||||
/* ════ Tables ════ */
|
||||
.history-table { border-radius: 8px; overflow: hidden; }
|
||||
.commit-hash {
|
||||
font-family: 'JetBrains Mono', 'Consolas', monospace;
|
||||
font-size: 12px;
|
||||
background: #f0f1f3;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
color: #409eff;
|
||||
}
|
||||
.text-muted { color: #c0c4cc; }
|
||||
|
||||
/* ════ Deploy config ════ */
|
||||
.deploy-config-card { border-radius: 12px; border: 1px solid #ebeef2; margin-bottom: 16px; }
|
||||
.deploy-history-card { border-radius: 12px; border: 1px solid #ebeef2; }
|
||||
.deploy-form .form-section {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
}
|
||||
.deploy-form .form-section:last-of-type { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #409eff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid #409eff15;
|
||||
}
|
||||
|
||||
.form-row { display: flex; gap: 16px; }
|
||||
.form-row > * { flex: 1; }
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
</style>
|
||||
21
web/vite.config.js
Normal file
21
web/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
8
web/web.iml
Normal file
8
web/web.iml
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Reference in New Issue
Block a user