Initial commit

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-18 21:19:47 +08:00
commit 3f7bdf0222
49 changed files with 7818 additions and 0 deletions

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
# Binary
ci-server
ci-server.exe
# Data directory (runtime)
data/
# IDE
.idea/
.vscode/
*.swp
*.swo

30
Makefile Normal file
View 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

Binary file not shown.

9
ci.iml Normal file
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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
View File

13
web/index.html Normal file
View 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
View File

@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

2836
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

25
web/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

179
web/src/App.vue Normal file
View 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
View 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
View 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
View 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
View 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;
}
}

View File

@@ -0,0 +1,44 @@
<script setup>
defineProps({
msg: {
type: String,
required: true,
},
})
</script>
<template>
<div class="greetings">
<h1 class="green">{{ msg }}</h1>
<h3>
Youve 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>

View 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>
Vues
<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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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>&nbsp; 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>

View 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 &amp; 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>

View 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-----&#10;...&#10;-----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
View 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
View 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>