feat: FlowStack 发布系统首个可运行版本 (gin + React)

- Go gin 后端:流水线/节点/连线/运行/日志/GitOps/插件 REST API,
  SQLite(GORM) 自动建表 + seed,模拟执行引擎 + SSE 实时日志推送,
  前端产物 go:embed 嵌入单二进制,一个端口同时服务 API 与页面
- React 19 前端:画布式流水线编辑器(React Flow + Tekton 任务库)、
  GitOps 部署、批量发布、发布记录等 14 个模块页面,
  tRPC 全部替换为 REST(TanStack Query)
- 首次启动生成随机密码管理员账号(日志 + data/initial-admin-password.txt)
- run.sh 一键构建启动(默认端口 9527,支持 start/stop/restart/build/dev)
This commit is contained in:
Logan.Li committed 2026-10-27 02:44:10 +08:00
commit 04d1342b27
115 files changed
+23268

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
module flowstack
go 1.24
require (
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/glebarez/sqlite v1.11.0
golang.org/x/crypto v0.40.0
gorm.io/gorm v1.30.0
)
require (
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // 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.27.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/uuid v1.3.0 // 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/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/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.25.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.27.0 // indirect
golang.org/x/tools v0.34.0 // indirect
google.golang.org/protobuf v1.36.9 // 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
)
+118
View File
@@ -0,0 +1,118 @@
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
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/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
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.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
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/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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
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.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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/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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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/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/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.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
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/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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
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.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
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.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
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=
+51
View File
@@ -0,0 +1,51 @@
// Package crypto 提供口令哈希原语(argon2id,与 opsx-loki 同参数)。
package crypto
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"math/big"
"golang.org/x/crypto/argon2"
)
// HashPassword 生成 argon2id 口令哈希(salt+hash base64 编码)
func HashPassword(password string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
combined := append(salt, hash...)
return base64.StdEncoding.EncodeToString(combined), nil
}
// VerifyPassword 常数时间比对口令哈希
func VerifyPassword(password, hashStr string) bool {
combined, err := base64.StdEncoding.DecodeString(hashStr)
if err != nil || len(combined) < 16 {
return false
}
salt := combined[:16]
hash := combined[16:]
newHash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
return subtle.ConstantTimeCompare(hash, newHash) == 1
}
// passwordAlphabet 去掉易混淆字符(0O1lI)的密码字母表
const passwordAlphabet = "23456789abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ"
// RandomPassword 生成 n 位随机密码(crypto/rand,无易混淆字符)
func RandomPassword(n int) (string, error) {
max := big.NewInt(int64(len(passwordAlphabet)))
buf := make([]byte, n)
for i := range buf {
idx, err := rand.Int(rand.Reader, max)
if err != nil {
return "", err
}
buf[i] = passwordAlphabet[idx.Int64()]
}
return string(buf), nil
}
+167
View File
@@ -0,0 +1,167 @@
package database
import (
"log"
"os"
"path/filepath"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"flowstack/internal/crypto"
"flowstack/internal/model"
)
var DB *gorm.DB
// DataDir 数据目录(密码文件等运行时产物存放处)
var DataDir string
// Init 打开 SQLite、自动建表并写入示例数据(仅首次)
func Init() error {
DataDir = filepath.Join(mustDir(), "data")
if err := os.MkdirAll(DataDir, 0o755); err != nil {
return err
}
db, err := gorm.Open(sqlite.Open(filepath.Join(DataDir, "flowstack.db")), &gorm.Config{})
if err != nil {
return err
}
DB = db
if err := DB.AutoMigrate(
&model.User{},
&model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{},
&model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{},
&model.Application{}, &model.Plugin{},
); err != nil {
return err
}
seedAdmin()
seedDemoData()
return nil
}
// mustDir 返回二进制所在目录;go run 时回退到当前目录
func mustDir() string {
if exe, err := os.Executable(); err == nil {
return filepath.Dir(exe)
}
return "."
}
// seedAdmin 首次启动生成随机密码的管理员账号:
// 密码打印到日志("已生成管理员账号",grep -A6 可见)并写入 data/initial-admin-password.txt。
func seedAdmin() {
var count int64
DB.Model(&model.User{}).Count(&count)
if count > 0 {
return
}
password, err := crypto.RandomPassword(16)
if err != nil {
log.Printf("[seed] 管理员密码生成失败: %v", err)
return
}
hash, err := crypto.HashPassword(password)
if err != nil {
log.Printf("[seed] 管理员密码哈希失败: %v", err)
return
}
DB.Create(&model.User{
Username: "admin", Password: hash, Nickname: "Administrator", Role: "admin",
})
pwdFile := filepath.Join(DataDir, "initial-admin-password.txt")
if err := os.WriteFile(pwdFile, []byte("admin / "+password+"\n"), 0o600); err != nil {
log.Printf("[seed] 密码文件写入失败: %v", err)
}
log.Println("========================================================")
log.Println("[seed] 已生成管理员账号(首次启动)")
log.Printf("[seed] 用户名: admin")
log.Printf("[seed] 初始密码: %s", password)
log.Printf("[seed] 密码已写入: %s", pwdFile)
log.Println("[seed] 请登录后立即修改密码")
log.Println("========================================================")
}
func seedDemoData() {
var count int64
DB.Model(&model.Pipeline{}).Count(&count)
if count > 0 {
return
}
log.Println("[seed] 首次启动,写入示例数据…")
// ---- 流水线 1:aiops-backend(Go + K8s) ----
backend := model.Pipeline{
Name: "aiops-backend", Description: "AIOps 后端服务(Go + K8s 滚动发布)",
Type: "native", SourceRepo: "https://gitee.com/attacker/aiops.git", SourceBranch: "master",
DeployType: "k8s", Env: "production",
}
DB.Create(&backend)
DB.Create([]model.PipelineNode{
{PipelineID: backend.ID, NodeID: "git-clone-1", Type: "source", Name: "git-clone", Category: "source", PositionX: 60, PositionY: 220, Width: 220, Height: 120,
Config: map[string]interface{}{"url": "https://gitee.com/attacker/aiops.git", "revision": "master"}},
{PipelineID: backend.ID, NodeID: "unit-test-1", Type: "test", Name: "unit-test", Category: "test", PositionX: 400, PositionY: 60, Width: 220, Height: 120,
Config: map[string]interface{}{"test-command": "go test -v -race ./..."}},
{PipelineID: backend.ID, NodeID: "kaniko-build-1", Type: "build", Name: "kaniko-build", Category: "build", PositionX: 400, PositionY: 380, Width: 220, Height: 120,
Config: map[string]interface{}{"dockerfile": "Dockerfile", "image": "registry.example.com/aiops/aiops-backend"}},
{PipelineID: backend.ID, NodeID: "trivy-scan-1", Type: "security", Name: "trivy-scan", Category: "security", PositionX: 740, PositionY: 380, Width: 220, Height: 120,
Config: map[string]interface{}{"image": "registry.example.com/aiops/aiops-backend", "severity": "HIGH,CRITICAL"}},
{PipelineID: backend.ID, NodeID: "kubectl-deploy-1", Type: "deploy", Name: "kubectl-deploy", Category: "deploy", PositionX: 1080, PositionY: 220, Width: 220, Height: 120,
Config: map[string]interface{}{"manifest": "k8s/production/", "namespace": "aiops"}},
})
DB.Create([]model.PipelineEdge{
{PipelineID: backend.ID, EdgeID: "e-clone-test", Source: "git-clone-1", Target: "unit-test-1", Animated: true},
{PipelineID: backend.ID, EdgeID: "e-clone-build", Source: "git-clone-1", Target: "kaniko-build-1", Animated: true},
{PipelineID: backend.ID, EdgeID: "e-build-scan", Source: "kaniko-build-1", Target: "trivy-scan-1", Animated: true},
{PipelineID: backend.ID, EdgeID: "e-test-deploy", Source: "unit-test-1", Target: "kubectl-deploy-1", Animated: true},
{PipelineID: backend.ID, EdgeID: "e-scan-deploy", Source: "trivy-scan-1", Target: "kubectl-deploy-1", Animated: true},
})
// ---- 流水线 2:aiops-web(Vue + 主机部署) ----
web := model.Pipeline{
Name: "aiops-web", Description: "AIOps 前端(Vue + 主机 Nginx 发布)",
Type: "native", SourceRepo: "https://gitee.com/attacker/aiops-web.git", SourceBranch: "master",
DeployType: "host", Env: "production",
}
DB.Create(&web)
DB.Create([]model.PipelineNode{
{PipelineID: web.ID, NodeID: "git-clone-1", Type: "source", Name: "git-clone", Category: "source", PositionX: 60, PositionY: 200, Width: 220, Height: 120,
Config: map[string]interface{}{"url": "https://gitee.com/attacker/aiops-web.git", "revision": "master"}},
{PipelineID: web.ID, NodeID: "npm-build-1", Type: "build", Name: "npm build", Category: "build", PositionX: 400, PositionY: 60, Width: 220, Height: 120,
Config: map[string]interface{}{"script": "npm ci && npm run build"}},
{PipelineID: web.ID, NodeID: "npm-audit-1", Type: "security", Name: "npm audit", Category: "security", PositionX: 400, PositionY: 340, Width: 220, Height: 120,
Config: map[string]interface{}{"severity": "high"}},
{PipelineID: web.ID, NodeID: "host-deploy-1", Type: "deploy", Name: "主机部署", Category: "deploy", PositionX: 760, PositionY: 200, Width: 220, Height: 120,
Config: map[string]interface{}{"主机组": "production-group", "目标路径": "/usr/share/nginx/html", "分批数量": "2"}},
{PipelineID: web.ID, NodeID: "cdn-refresh-1", Type: "custom", Name: "刷新 CDN", Category: "custom", PositionX: 1120, PositionY: 200, Width: 220, Height: 120,
Config: map[string]interface{}{"刷新URL": "https://aiops.example.com/*"}},
})
DB.Create([]model.PipelineEdge{
{PipelineID: web.ID, EdgeID: "e-clone-npm", Source: "git-clone-1", Target: "npm-build-1", Animated: true},
{PipelineID: web.ID, EdgeID: "e-clone-audit", Source: "git-clone-1", Target: "npm-audit-1", Animated: true},
{PipelineID: web.ID, EdgeID: "e-npm-deploy", Source: "npm-build-1", Target: "host-deploy-1", Animated: true},
{PipelineID: web.ID, EdgeID: "e-audit-deploy", Source: "npm-audit-1", Target: "host-deploy-1", Animated: true},
{PipelineID: web.ID, EdgeID: "e-deploy-cdn", Source: "host-deploy-1", Target: "cdn-refresh-1", Animated: true},
})
// ---- GitOps 应用 ----
DB.Create([]model.Application{
{Name: "aiops-backend", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-backend", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "automated", AutoPrune: true, SelfHeal: true},
{Name: "aiops-web", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-web", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "manual"},
})
// ---- 已安装插件 ----
DB.Create([]model.Plugin{
{PluginID: "jenkins-import", Name: "Jenkins 导入", Description: "一键导入既有 Jenkins Job 为 FlowStack 流水线", Version: "1.2.0", Author: "FlowStack Team", Category: "部署", Enabled: true},
{PluginID: "webhook-notify", Name: "Webhook 通知", Description: "发布成功/失败后回调企业微信、钉钉", Version: "1.0.3", Author: "FlowStack Team", Category: "通知", Enabled: true},
{PluginID: "k8s-rollout", Name: "K8s 滚动发布", Description: "Deployment 滚动更新与回滚", Version: "2.3.1", Author: "CloudNative", Category: "构建", Enabled: false},
})
}
+36
View File
@@ -0,0 +1,36 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"flowstack/internal/crypto"
"flowstack/internal/database"
"flowstack/internal/model"
)
// Login POST /api/auth/login {username, password}
func Login(c *gin.Context) {
var body struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名和密码不能为空"})
return
}
var user model.User
if err := database.DB.Where("username = ?", body.Username).First(&user).Error; err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
if !crypto.VerifyPassword(body.Password, user.Password) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true, "username": user.Username, "nickname": user.Nickname, "role": user.Role,
})
}
+67
View File
@@ -0,0 +1,67 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"flowstack/internal/database"
"flowstack/internal/model"
)
// argoApps 模拟 Argo CD 应用状态(演示用)
var argoApps = []gin.H{
{"metadata": gin.H{"name": "aiops-backend"},
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-backend", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
"status": gin.H{"sync": gin.H{"status": "Synced"}, "health": gin.H{"status": "Healthy"}, "operationState": gin.H{"phase": "Succeeded"}}},
{"metadata": gin.H{"name": "aiops-web"},
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-web", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
"status": gin.H{"sync": gin.H{"status": "OutOfSync"}, "health": gin.H{"status": "Progressing"}, "operationState": gin.H{"phase": "Running"}}},
}
// ListGitOpsApps GET /api/gitops/apps
func ListGitOpsApps(c *gin.Context) {
var apps []model.Application
database.DB.Order("updated_at DESC").Find(&apps)
c.JSON(http.StatusOK, apps)
}
// ArgoList GET /api/gitops/argo/apps
func ArgoList(c *gin.Context) {
c.JSON(http.StatusOK, argoApps)
}
// ArgoSync POST /api/gitops/argo/sync {appName}
func ArgoSync(c *gin.Context) {
var body struct {
AppName string `json:"appName" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "appName required"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "appName": body.AppName, "message": "Sync triggered"})
}
// ArgoResourceTree GET /api/gitops/argo/:appName/tree
func ArgoResourceTree(c *gin.Context) {
name := c.Param("appName")
c.JSON(http.StatusOK, gin.H{
"nodes": []gin.H{
{"uid": "1", "name": name + "-deployment", "kind": "Deployment", "namespace": "aiops", "status": "healthy", "resourceVersion": "v1"},
{"uid": "2", "name": name + "-svc", "kind": "Service", "namespace": "aiops", "status": "healthy", "resourceVersion": "v1"},
{"uid": "3", "name": name + "-hpa", "kind": "HorizontalPodAutoscaler", "namespace": "aiops", "status": "healthy", "resourceVersion": "v2"},
},
"edges": []gin.H{{"source": "1", "target": "2"}, {"source": "1", "target": "3"}},
})
}
// ArgoDiff GET /api/gitops/argo/:appName/diff
func ArgoDiff(c *gin.Context) {
name := c.Param("appName")
c.JSON(http.StatusOK, gin.H{
"diffs": []gin.H{
{"kind": "Deployment", "name": name, "namespace": "aiops", "diff": "- image: registry.example.com/aiops/" + name + ":202610261130\n+ image: registry.example.com/aiops/" + name + ":202610271200", "status": "modified"},
},
})
}
+56
View File
@@ -0,0 +1,56 @@
package handler
import (
"encoding/json"
"fmt"
"sync"
)
// Hub 按 runId 分组的 SSE 订阅中心
type Hub struct {
mu sync.Mutex
subs map[string]map[chan []byte]struct{}
}
func NewHub() *Hub {
return &Hub{subs: make(map[string]map[chan []byte]struct{})}
}
func (h *Hub) Subscribe(runId string) chan []byte {
ch := make(chan []byte, 64)
h.mu.Lock()
defer h.mu.Unlock()
if h.subs[runId] == nil {
h.subs[runId] = make(map[chan []byte]struct{})
}
h.subs[runId][ch] = struct{}{}
return ch
}
func (h *Hub) Unsubscribe(runId string, ch chan []byte) {
h.mu.Lock()
defer h.mu.Unlock()
if set := h.subs[runId]; set != nil {
delete(set, ch)
if len(set) == 0 {
delete(h.subs, runId)
}
}
}
// Publish 向 runId 的所有订阅者推送一条 SSE 帧(event + data)
func (h *Hub) Publish(runId, event string, payload any) {
data, err := json.Marshal(payload)
if err != nil {
return
}
frame := []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data))
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.subs[runId] {
select {
case ch <- frame:
default: // 订阅者消费过慢则丢弃,避免阻塞模拟引擎
}
}
}
+162
View File
@@ -0,0 +1,162 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/database"
"flowstack/internal/model"
)
// ListPipelines GET /api/pipelines
func ListPipelines(c *gin.Context) {
var list []model.Pipeline
database.DB.Order("updated_at DESC").Find(&list)
c.JSON(http.StatusOK, list)
}
// GetPipeline GET /api/pipelines/:id —— 详情含画布节点与连线
func GetPipeline(c *gin.Context) {
var p model.Pipeline
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
var nodes []model.PipelineNode
database.DB.Where("pipeline_id = ?", p.ID).Order("position_x").Find(&nodes)
var edges []model.PipelineEdge
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
c.JSON(http.StatusOK, gin.H{
"pipeline": p, "nodes": nodes, "edges": edges,
"name": p.Name, "description": p.Description, "type": p.Type,
"sourceRepo": p.SourceRepo, "sourceBranch": p.SourceBranch,
"gitopsRepo": p.GitopsRepo, "gitopsPath": p.GitopsPath,
"deployType": p.DeployType, "env": p.Env,
"id": p.ID, "createdAt": p.CreatedAt, "updatedAt": p.UpdatedAt,
})
}
// CreatePipeline POST /api/pipelines
func CreatePipeline(c *gin.Context) {
var input struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Type string `json:"type"`
SourceRepo string `json:"sourceRepo"`
SourceBranch string `json:"sourceBranch"`
DeployType string `json:"deployType"`
Env string `json:"env"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "name 不能为空"})
return
}
setDefault(&input.Type, "native")
setDefault(&input.SourceBranch, "main")
setDefault(&input.DeployType, "host")
setDefault(&input.Env, "staging")
p := model.Pipeline{
Name: input.Name, Description: input.Description, Type: input.Type,
SourceRepo: input.SourceRepo, SourceBranch: input.SourceBranch,
DeployType: input.DeployType, Env: input.Env,
}
if err := database.DB.Create(&p).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, p)
}
// UpdatePipeline PUT /api/pipelines/:id
func UpdatePipeline(c *gin.Context) {
var p model.Pipeline
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
var input map[string]any
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
allowed := map[string]bool{"name": true, "description": true, "sourceRepo": true, "sourceBranch": true, "gitopsRepo": true, "gitopsPath": true, "deployType": true, "env": true}
updates := map[string]any{}
for k, v := range input {
if allowed[k] {
updates[k] = v
}
}
if len(updates) > 0 {
updates["updated_at"] = time.Now()
database.DB.Model(&p).Updates(updates)
}
c.JSON(http.StatusOK, p)
}
// DeletePipeline DELETE /api/pipelines/:id —— 级联删除节点与连线
func DeletePipeline(c *gin.Context) {
id := c.Param("id")
database.DB.Where("pipeline_id = ?", id).Delete(&model.PipelineEdge{})
database.DB.Where("pipeline_id = ?", id).Delete(&model.PipelineNode{})
database.DB.Delete(&model.Pipeline{}, id)
c.JSON(http.StatusOK, gin.H{"success": true})
}
// SaveNodes PUT /api/pipelines/:id/nodes —— 全量覆盖保存
func SaveNodes(c *gin.Context) {
pipelineID := c.Param("id")
var p model.Pipeline
if err := database.DB.First(&p, pipelineID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
var body struct {
Nodes []model.PipelineNode `json:"nodes"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
database.DB.Where("pipeline_id = ?", p.ID).Delete(&model.PipelineNode{})
for i := range body.Nodes {
body.Nodes[i].ID = 0
body.Nodes[i].PipelineID = p.ID
database.DB.Create(&body.Nodes[i])
}
database.DB.Model(&p).Update("updated_at", time.Now())
c.JSON(http.StatusOK, gin.H{"success": true})
}
// SaveEdges PUT /api/pipelines/:id/edges —— 全量覆盖保存
func SaveEdges(c *gin.Context) {
pipelineID := c.Param("id")
var p model.Pipeline
if err := database.DB.First(&p, pipelineID).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
var body struct {
Edges []model.PipelineEdge `json:"edges"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
database.DB.Where("pipeline_id = ?", p.ID).Delete(&model.PipelineEdge{})
for i := range body.Edges {
body.Edges[i].ID = 0
body.Edges[i].PipelineID = p.ID
database.DB.Create(&body.Edges[i])
}
database.DB.Model(&p).Update("updated_at", time.Now())
c.JSON(http.StatusOK, gin.H{"success": true})
}
func setDefault(v *string, def string) {
if *v == "" {
*v = def
}
}
+87
View File
@@ -0,0 +1,87 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"flowstack/internal/database"
"flowstack/internal/model"
)
// availablePlugins 可安装插件市场(演示数据)
var availablePlugins = []gin.H{
{"pluginId": "gitops-sync", "name": "GitOps Sync", "description": "Argo CD integration", "version": "1.3.0", "author": "OpsX Team", "category": "部署", "installs": 3420},
{"pluginId": "tekton-tasks", "name": "Tekton Tasks", "description": "Cloud-native CI tasks", "version": "2.1.0", "author": "CloudNative", "category": "构建", "installs": 5180},
{"pluginId": "dark-theme", "name": "Dark Theme", "description": "Dark UI theme", "version": "1.1.0", "author": "DesignLab", "category": "主题", "installs": 8920},
{"pluginId": "sonarqube", "name": "SonarQube Scanner", "description": "Code quality scan", "version": "3.2.0", "author": "CodeQuality", "category": "扫描", "installs": 2150},
{"pluginId": "vault-secrets", "name": "Vault Secrets", "description": "HashiCorp Vault integration", "version": "1.6.0", "author": "SecurityTeam", "category": "安全", "installs": 980},
{"pluginId": "matrix-build", "name": "Matrix Build", "description": "Multi-dimension parallel build", "version": "2.0.0", "author": "BuildMaster", "category": "构建", "installs": 1670},
{"pluginId": "slack-notify", "name": "Slack Notify", "description": "Slack notifications", "version": "1.3.0", "author": "NotifyHub", "category": "通知", "installs": 2890},
}
// ListPlugins GET /api/plugins
func ListPlugins(c *gin.Context) {
var list []model.Plugin
database.DB.Order("created_at DESC").Find(&list)
c.JSON(http.StatusOK, list)
}
// AvailablePlugins GET /api/plugins/available
func AvailablePlugins(c *gin.Context) {
c.JSON(http.StatusOK, availablePlugins)
}
// InstallPlugin POST /api/plugins/install
func InstallPlugin(c *gin.Context) {
var body struct {
PluginID string `json:"pluginId" binding:"required"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Version string `json:"version" binding:"required"`
Author string `json:"author"`
Category string `json:"category"`
EntryPath string `json:"entryPath"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if body.Category == "" {
body.Category = "general"
}
p := model.Plugin{
PluginID: body.PluginID, Name: body.Name, Description: body.Description,
Version: body.Version, Author: body.Author, Category: body.Category, EntryPath: body.EntryPath,
Installed: true, Enabled: true, Config: map[string]any{},
}
if err := database.DB.Create(&p).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, p)
}
// TogglePlugin POST /api/plugins/:id/toggle {enabled}
func TogglePlugin(c *gin.Context) {
var p model.Plugin
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "plugin not found"})
return
}
var body struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
database.DB.Model(&p).Update("enabled", body.Enabled)
c.JSON(http.StatusOK, gin.H{"success": true, "enabled": body.Enabled})
}
// UninstallPlugin DELETE /api/plugins/:id
func UninstallPlugin(c *gin.Context) {
database.DB.Delete(&model.Plugin{}, c.Param("id"))
c.JSON(http.StatusOK, gin.H{"success": true})
}
+272
View File
@@ -0,0 +1,272 @@
package handler
import (
"encoding/json"
"fmt"
"net/http"
"sort"
"time"
"github.com/gin-gonic/gin"
"flowstack/internal/database"
"flowstack/internal/model"
)
// CreateRun 创建运行并启动模拟执行引擎
func CreateRun(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
var p model.Pipeline
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
return
}
var body struct {
Branch string `json:"branch"`
Env string `json:"env"`
TriggerBy string `json:"triggerBy"`
}
_ = c.ShouldBindJSON(&body)
if body.Branch == "" {
body.Branch = p.SourceBranch
}
if body.Env == "" {
body.Env = p.Env
}
if body.TriggerBy == "" {
body.TriggerBy = "admin"
}
var nodes []model.PipelineNode
database.DB.Where("pipeline_id = ?", p.ID).Find(&nodes)
if len(nodes) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "流水线没有节点,请先在画布中编排并保存"})
return
}
var edges []model.PipelineEdge
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
nodesJSON, _ := json.Marshal(nodes)
edgesJSON, _ := json.Marshal(edges)
run := model.PipelineRun{
RunID: fmt.Sprintf("pr-%d", time.Now().UnixMilli()),
PipelineID: p.ID, PipelineName: p.Name,
Branch: body.Branch, Env: body.Env, DeployType: p.DeployType, Type: p.Type,
TriggerBy: body.TriggerBy, TriggerType: "manual",
Status: "running", StartedAt: time.Now(),
NodesSnapshot: string(nodesJSON), EdgesSnapshot: string(edgesJSON),
}
if err := database.DB.Create(&run).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// 按画布 X 坐标从左到右生成执行阶段
sort.Slice(nodes, func(i, j int) bool { return nodes[i].PositionX < nodes[j].PositionX })
stages := make([]model.RunStage, len(nodes))
for i, n := range nodes {
stages[i] = model.RunStage{RunID: run.ID, NodeID: n.NodeID, Name: n.Name, Status: "pending"}
database.DB.Create(&stages[i])
}
go simulateRun(hub, run, stages)
c.JSON(http.StatusOK, run)
}
}
// simulateRun 模拟执行:逐阶段 pending→running→success,逐条写日志并 SSE 推送
func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
start := time.Now()
emit := func(level, msg string, stageID uint) {
entry := model.LogEntry{RunID: run.ID, StageID: stageID, Level: level, Message: msg, Source: "runner", Timestamp: time.Now()}
database.DB.Create(&entry)
hub.Publish(run.RunID, "log", entry)
}
setStage := func(stage *model.RunStage, status string) {
now := time.Now()
updates := map[string]any{"status": status}
if status == "running" {
stage.StartedAt = &now
updates["started_at"] = now
} else {
stage.CompletedAt = &now
updates["completed_at"] = now
exit := 0
stage.ExitCode = &exit
updates["exit_code"] = exit
}
database.DB.Model(&model.RunStage{}).Where("id = ?", stage.ID).Updates(updates)
stage.Status = status
hub.Publish(run.RunID, "stage", gin.H{"stageId": stage.ID, "nodeId": stage.NodeID, "name": stage.Name, "status": status})
}
setRun := func(status string) {
now := time.Now()
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
}
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
for i := range stages {
st := &stages[i]
setStage(st, "running")
for _, line := range stageScript(st) {
emit("info", line, st.ID)
time.Sleep(500 * time.Millisecond)
}
setStage(st, "success")
}
setRun("success")
}
// stageScript 按节点类别生成贴近真实构建过程的日志
func stageScript(st *model.RunStage) []string {
switch st.Name {
case "git-clone":
return []string{
fmt.Sprintf("$ git clone --depth=50 --branch=master %s", "https://gitee.com/attacker/aiops.git"),
"Cloning into 'source'...",
"HEAD is now at 8f3c21a feat: release v2.4.0",
}
case "unit-test":
return []string{
"$ go test -v -race ./...",
"ok \tflowstack/internal/handler\t2.318s",
"ok \tflowstack/internal/model\t0.402s",
"coverage: 87.4% of statements",
}
case "kaniko-build":
return []string{
"$ kaniko --dockerfile Dockerfile --destination registry.example.com/aiops/aiops-backend:" + time.Now().Format("200601021504"),
"INFO[0002] Retrieving image manifest golang:1.24-alpine",
"INFO[0018] Executing 12 build steps",
"INFO[0041] Pushing image to registry.example.com",
}
case "trivy-scan":
return []string{
"$ trivy image --severity HIGH,CRITICAL registry.example.com/aiops/aiops-backend:latest",
"alpine (apk): 0 HIGH, 0 CRITICAL",
"Scan completed: no critical vulnerabilities found",
}
case "kubectl-deploy":
return []string{
"$ kubectl set image deployment/aiops-backend app=registry.example.com/aiops/aiops-backend:" + time.Now().Format("200601021504") + " -n aiops",
"deployment.apps/aiops-backend image updated",
"Waiting for rollout: 3/3 updated",
"deployment \"aiops-backend\" successfully rolled out",
}
case "npm build":
return []string{
"$ npm ci --registry=https://registry.npmmirror.com",
"$ npm run build",
"vite v7.x building for production...",
"✓ built in 18.4s",
}
case "npm audit":
return []string{
"$ npm audit --audit-level=high",
"found 0 vulnerabilities",
}
case "主机部署":
return []string{
"$ tar zcf dist.tar.gz dist/ && scp dist.tar.gz production-group:/home/admin/app/",
"$ rsync -avz --delete /tmp/dist/ /usr/share/nginx/html/",
"$ nginx -t && nginx -s reload",
"2/2 主机部署完成",
}
case "刷新 CDN":
return []string{
"$ curl -X POST https://cdn.aliyuncs.com/api/refresh",
`{"urls":["https://aiops.example.com/*"]}`,
"RefreshTaskId: 202610271200****",
}
default:
return []string{
fmt.Sprintf("[%s] 开始执行…", st.Name),
fmt.Sprintf("[%s] 执行完成 (exit 0)", st.Name),
}
}
}
// ListRuns GET /api/runs?pipelineId=&status=
func ListRuns(c *gin.Context) {
q := database.DB.Model(&model.PipelineRun{}).Order("started_at DESC").Limit(100)
if v := c.Query("pipelineId"); v != "" {
q = q.Where("pipeline_id = ?", v)
}
if v := c.Query("status"); v != "" && v != "all" {
q = q.Where("status = ?", v)
}
var runs []model.PipelineRun
q.Find(&runs)
c.JSON(http.StatusOK, runs)
}
// GetRun GET /api/runs/:runId
func GetRun(c *gin.Context) {
var run model.PipelineRun
if err := database.DB.Where("run_id = ?", c.Param("runId")).First(&run).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
return
}
var stages []model.RunStage
database.DB.Where("run_id = ?", run.ID).Order("id").Find(&stages)
var logs []model.LogEntry
database.DB.Where("run_id = ?", run.ID).Order("timestamp").Find(&logs)
c.JSON(http.StatusOK, gin.H{"run": run, "stages": stages, "logs": logs})
}
// StreamLogs GET /api/runs/:runId/logs/stream —— SSE:先回放历史日志再实时推送
func StreamLogs(hub *Hub) gin.HandlerFunc {
return func(c *gin.Context) {
runId := c.Param("runId")
var run model.PipelineRun
if err := database.DB.Where("run_id = ?", runId).First(&run).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
return
}
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flush := func() { if f, ok := c.Writer.(http.Flusher); ok { f.Flush() } }
fmt.Fprintf(c.Writer, "event: connected\ndata: {\"runId\":\"%s\"}\n\n", runId)
flush()
// 回放历史日志
var history []model.LogEntry
database.DB.Where("run_id = ?", run.ID).Order("timestamp").Find(&history)
for _, entry := range history {
data, _ := json.Marshal(entry)
fmt.Fprintf(c.Writer, "event: log\ndata: %s\n\n", data)
}
if len(history) > 0 {
flush()
}
ch := hub.Subscribe(runId)
defer hub.Unsubscribe(runId, ch)
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
ctx := c.Request.Context()
for {
select {
case <-ctx.Done():
return
case <-heartbeat.C:
fmt.Fprint(c.Writer, ": ping\n\n")
flush()
case frame := <-ch:
c.Writer.Write(frame)
flush()
}
}
}
}
+135
View File
@@ -0,0 +1,135 @@
package model
import "time"
// User 系统用户
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"uniqueIndex" json:"username"`
Password string `json:"-"` // argon2id 哈希,绝不返回给前端
Nickname string `json:"nickname"`
Role string `gorm:"default:user" json:"role"` // admin | user
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Pipeline 流水线定义
type Pipeline struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"index" json:"name"`
Description string `json:"description"`
Type string `gorm:"default:native" json:"type"` // native | jenkins | tekton
SourceRepo string `json:"sourceRepo"`
SourceBranch string `gorm:"default:main" json:"sourceBranch"`
GitopsRepo string `json:"gitopsRepo"`
GitopsPath string `json:"gitopsPath"`
DeployType string `gorm:"default:host" json:"deployType"` // host | k8s | gitops
Env string `gorm:"default:staging" json:"env"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// PipelineNode 画布节点(JSON 字段名与前端 React Flow 对齐)
type PipelineNode struct {
ID uint `gorm:"primaryKey" json:"dbId"`
PipelineID uint `gorm:"index" json:"pipelineId"`
NodeID string `gorm:"index" json:"nodeId"`
Type string `json:"type"`
Name string `json:"name"`
PositionX float64 `json:"positionX"`
PositionY float64 `json:"positionY"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Category string `json:"category"` // source|build|test|security|deploy|gitops|custom
Config map[string]interface{} `gorm:"serializer:json" json:"config"`
}
// PipelineEdge 画布连线
type PipelineEdge struct {
ID uint `gorm:"primaryKey" json:"dbId"`
PipelineID uint `gorm:"index" json:"pipelineId"`
EdgeID string `gorm:"index" json:"edgeId"`
Source string `json:"source"`
Target string `json:"target"`
Label string `json:"label"`
Animated bool `json:"animated"`
}
// PipelineRun 一次流水线运行
type PipelineRun struct {
ID uint `gorm:"primaryKey" json:"id"`
RunID string `gorm:"uniqueIndex" json:"runId"` // pr-<timestamp>
PipelineID uint `gorm:"index" json:"pipelineId"`
PipelineName string `json:"pipelineName"`
Branch string `gorm:"default:main" json:"branch"`
Env string `gorm:"default:staging" json:"env"`
DeployType string `gorm:"default:host" json:"deployType"`
Type string `gorm:"default:native" json:"type"`
TriggerBy string `gorm:"default:system" json:"triggerBy"`
TriggerType string `gorm:"default:manual" json:"triggerType"` // manual|webhook|cron|batch
Status string `gorm:"default:pending" json:"status"` // pending|running|success|failed|cancelled
DurationMs int64 `json:"durationMs"`
NodesSnapshot string `json:"nodesSnapshot"`
EdgesSnapshot string `json:"edgesSnapshot"`
StartedAt time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// RunStage 运行中的阶段(对应一个节点)
type RunStage struct {
ID uint `gorm:"primaryKey" json:"id"`
RunID uint `gorm:"index" json:"runId"`
NodeID string `json:"nodeId"`
Name string `json:"name"`
PodName string `json:"podName"`
Status string `gorm:"default:pending" json:"status"` // pending|running|success|failed|skipped
ExitCode *int `json:"exitCode"`
StartedAt *time.Time `json:"startedAt"`
CompletedAt *time.Time `json:"completedAt"`
}
// LogEntry 运行日志
type LogEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
RunID uint `gorm:"index" json:"runId"`
StageID uint `json:"stageId"`
Level string `gorm:"default:info" json:"level"` // debug|info|warn|error
Message string `json:"message"`
Source string `gorm:"default:runner" json:"source"`
Timestamp time.Time `json:"timestamp"`
}
// Application GitOps 应用
type Application struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"uniqueIndex" json:"name"`
Repo string `json:"repo"`
Branch string `gorm:"default:main" json:"branch"`
Path string `json:"path"`
Cluster string `gorm:"default:default" json:"cluster"`
Namespace string `gorm:"default:default" json:"namespace"`
ManifestType string `gorm:"default:plain" json:"manifestType"` // helm|kustomize|plain
SyncPolicy string `gorm:"default:manual" json:"syncPolicy"` // manual|automated
AutoPrune bool `json:"autoPrune"`
SelfHeal bool `json:"selfHeal"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Plugin 插件
type Plugin struct {
ID uint `gorm:"primaryKey" json:"id"`
PluginID string `gorm:"uniqueIndex" json:"pluginId"`
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Author string `json:"author"`
Category string `gorm:"default:general" json:"category"`
EntryPath string `json:"entryPath"`
Installed bool `gorm:"default:true" json:"installed"`
Enabled bool `gorm:"default:true" json:"enabled"`
Config map[string]interface{} `gorm:"serializer:json" json:"config"`
CreatedAt time.Time `json:"createdAt"`
}
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"embed"
"io/fs"
"net/http"
"os"
"strings"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"flowstack/internal/database"
"flowstack/internal/handler"
)
// 前端产物在构建期拷贝到 static/ 并嵌入二进制(run.sh build)
//
//go:embed all:static
var staticFS embed.FS
func main() {
if err := database.Init(); err != nil {
panic("数据库初始化失败: " + err.Error())
}
hub := handler.NewHub()
r := gin.Default()
// ?_clear=1 时下发 Clear-Site-Data,用于清除浏览器缓存的错误重定向等脏缓存
// (历史 bug:/index.html 曾返回 301 → ./,浏览器永久缓存后反复触发 ERR_TOO_MANY_REDIRECTS)
r.Use(func(c *gin.Context) {
if c.Query("_clear") != "" {
c.Header("Clear-Site-Data", "\"cache\"")
}
c.Next()
})
r.Use(cors.New(cors.Config{
AllowAllOrigins: true,
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"*"},
AllowCredentials: false,
}))
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "app": "flowstack"})
})
api := r.Group("/api")
{
api.POST("/auth/login", handler.Login)
api.GET("/pipelines", handler.ListPipelines)
api.POST("/pipelines", handler.CreatePipeline)
api.GET("/pipelines/:id", handler.GetPipeline)
api.PUT("/pipelines/:id", handler.UpdatePipeline)
api.DELETE("/pipelines/:id", handler.DeletePipeline)
api.PUT("/pipelines/:id/nodes", handler.SaveNodes)
api.PUT("/pipelines/:id/edges", handler.SaveEdges)
api.POST("/pipelines/:id/runs", handler.CreateRun(hub))
api.GET("/runs", handler.ListRuns)
api.GET("/runs/:runId", handler.GetRun)
api.GET("/runs/:runId/logs/stream", handler.StreamLogs(hub))
api.GET("/gitops/apps", handler.ListGitOpsApps)
api.GET("/gitops/argo/apps", handler.ArgoList)
api.POST("/gitops/argo/sync", handler.ArgoSync)
api.GET("/gitops/argo/:appName/tree", handler.ArgoResourceTree)
api.GET("/gitops/argo/:appName/diff", handler.ArgoDiff)
api.GET("/plugins", handler.ListPlugins)
api.GET("/plugins/available", handler.AvailablePlugins)
api.POST("/plugins/install", handler.InstallPlugin)
api.POST("/plugins/:id/toggle", handler.TogglePlugin)
api.DELETE("/plugins/:id", handler.UninstallPlugin)
}
distFS, _ := fs.Sub(staticFS, "static")
r.NoRoute(func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if path != "" {
if f, err := distFS.Open(path); err == nil {
f.Close()
c.FileFromFS("/"+path, http.FS(distFS))
return
}
}
// SPA 路由回退:直接返回 index.html 内容
// (不能用 FileFromFS,http.FileServer 会把 /index.html 301 到 ./ 造成死循环)
index, err := fs.ReadFile(distFS, "index.html")
if err != nil {
c.String(http.StatusNotFound, "前端资源未构建")
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
})
port := os.Getenv("PORT")
if port == "" {
port = "9527"
}
if err := r.Run(":" + port); err != nil {
panic("服务启动失败: " + err.Error())
}
}
+10
View File
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="UTF-8"><title>FlowStack</title></head>
<body style="font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f6f8fa;">
<div style="text-align: center; color: #57606a;">
<h1 style="font-size: 20px;">FlowStack 前端尚未构建</h1>
<p>请在项目根目录执行 <code style="background:#eaeef2;padding:2px 6px;border-radius:4px;">./run.sh build</code> 生成前端产物后重新编译。</p>
</div>
</body>
</html>