Compare commits
47 Commits
95a6902dae
..
debug
| Author | SHA1 | Date | |
|---|---|---|---|
| abc6cb4e46 | |||
| 0660117c07 | |||
| 9ede6257f8 | |||
| f5b9b32a9f | |||
| e721cff3f8 | |||
| 7e54d62170 | |||
| 477dd94227 | |||
| c59d122e04 | |||
| ad92439770 | |||
| f1fc52bd6b | |||
| 24cc11bc8d | |||
| 10d899b50f | |||
| 2a8faaa9fe | |||
| c5e35b4c12 | |||
| f578b6eb51 | |||
| a2c71da3a0 | |||
| 28631865c8 | |||
| edb1458806 | |||
| ce73e915ca | |||
| baaa27005e | |||
| 84807b9ba9 | |||
| b99f60c7e5 | |||
| 6740dbb1b7 | |||
| 5c67c0287e | |||
| 8ab7fbc6b2 | |||
| d917a9e465 | |||
| 82c6e1bb15 | |||
| 68f3174f08 | |||
| 94be9799f4 | |||
| 3541fbdaae | |||
| 81f3ba52cc | |||
| 8dee5ac823 | |||
| 980526c630 | |||
| eb193b1b95 | |||
| 514e3e30b6 | |||
| 94ff261c9a | |||
| a44630cfea | |||
| b69f2e4c9a | |||
| d96f952d73 | |||
| 27e82f80f1 | |||
| 83427193bc | |||
| 28ef2dc1fd | |||
| 2ebf374413 | |||
| 3293915062 | |||
| 8913353e64 | |||
| 9992e254d5 | |||
| b75d95f9a7 |
@@ -0,0 +1 @@
|
|||||||
|
go.work.sum
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM golang:1.26.1 as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY proto/ proto/
|
||||||
|
COPY agent/ agent/
|
||||||
|
|
||||||
|
WORKDIR /app/agent
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
go mod download && \
|
||||||
|
CGO_ENABLED=0 go build -ldflags "-s -w" -o /agent .
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
systemd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /agent .
|
||||||
|
|
||||||
|
CMD ["./agent"]
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403214837-94be9799f47d
|
||||||
|
github.com/hpcloud/tail v1.0.0
|
||||||
|
github.com/samber/lo v1.53.0
|
||||||
|
golang.org/x/sync v0.20.0
|
||||||
|
google.golang.org/grpc v1.80.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.34.5
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
go.opentelemetry.io/otel/metric v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
|
golang.org/x/net v0.52.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7 // indirect
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||||
|
modernc.org/libc v1.55.3 // indirect
|
||||||
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
modernc.org/memory v1.8.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto => ../proto
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
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/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
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/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
|
||||||
|
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||||
|
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
|
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||||
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
||||||
|
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
||||||
|
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
||||||
|
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
||||||
|
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
||||||
|
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
||||||
|
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
||||||
|
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||||
|
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||||
|
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
|
||||||
|
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
|
||||||
|
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||||
|
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||||
|
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
|
||||||
|
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
|
||||||
|
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
|
||||||
|
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
||||||
|
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||||
|
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package buffer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BufferedLog represents a log entry stored for later delivery
|
||||||
|
type BufferedLog struct {
|
||||||
|
ID int64
|
||||||
|
Service string
|
||||||
|
Message string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogBuffer provides SQLite-backed log buffering
|
||||||
|
type LogBuffer struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLogBuffer creates a new log buffer with the given database path
|
||||||
|
func NewLogBuffer(dbPath string) (*LogBuffer, error) {
|
||||||
|
db, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create table if not exists
|
||||||
|
_, err = db.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS buffered_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
service TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
return nil, fmt.Errorf("failed to create table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create index for efficient ordering
|
||||||
|
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_created_at ON buffered_logs(created_at ASC)`)
|
||||||
|
|
||||||
|
return &LogBuffer{db: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the database connection
|
||||||
|
func (b *LogBuffer) Close() error {
|
||||||
|
return b.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store stores a log entry in the buffer
|
||||||
|
func (b *LogBuffer) Store(service, message string) error {
|
||||||
|
_, err := b.db.Exec(
|
||||||
|
"INSERT INTO buffered_logs (service, message) VALUES (?, ?)",
|
||||||
|
service, message,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// StoreBatch stores multiple log entries in a single transaction
|
||||||
|
func (b *LogBuffer) StoreBatch(entries []BufferedLog) error {
|
||||||
|
tx, err := b.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
stmt, err := tx.Prepare("INSERT INTO buffered_logs (service, message) VALUES (?, ?)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if _, err := stmt.Exec(entry.Service, entry.Message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPending retrieves pending logs in order of arrival, limited to batchSize
|
||||||
|
func (b *LogBuffer) GetPending(batchSize int) ([]BufferedLog, error) {
|
||||||
|
rows, err := b.db.Query(
|
||||||
|
"SELECT id, service, message, created_at FROM buffered_logs ORDER BY created_at ASC LIMIT ?",
|
||||||
|
batchSize,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []BufferedLog
|
||||||
|
for rows.Next() {
|
||||||
|
var log BufferedLog
|
||||||
|
var createdAt string
|
||||||
|
if err := rows.Scan(&log.ID, &log.Service, &log.Message, &createdAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.CreatedAt, _ = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
logs = append(logs, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a log entry from the buffer after successful delivery
|
||||||
|
func (b *LogBuffer) Delete(id int64) error {
|
||||||
|
_, err := b.db.Exec("DELETE FROM buffered_logs WHERE id = ?", id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBatch removes multiple log entries after successful delivery
|
||||||
|
func (b *LogBuffer) DeleteBatch(ids []int64) error {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := b.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
if _, err := tx.Exec("DELETE FROM buffered_logs WHERE id = ?", id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count returns the number of buffered logs
|
||||||
|
func (b *LogBuffer) Count() (int, error) {
|
||||||
|
var count int
|
||||||
|
err := b.db.QueryRow("SELECT COUNT(*) FROM buffered_logs").Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear removes all buffered logs
|
||||||
|
func (b *LogBuffer) Clear() error {
|
||||||
|
_, err := b.db.Exec("DELETE FROM buffered_logs")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// FlushToJSON exports buffered logs to JSON format for debugging
|
||||||
|
func (b *LogBuffer) FlushToJSON() ([]byte, error) {
|
||||||
|
logs, err := b.GetPending(1000)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return json.MarshalIndent(logs, "", " ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/commander"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CommanderClient struct {
|
||||||
|
cmder *commander.CommandExecutor
|
||||||
|
wg *sync.WaitGroup
|
||||||
|
id, label string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(
|
||||||
|
cmder *commander.CommandExecutor,
|
||||||
|
id, label string,
|
||||||
|
) CommanderClient {
|
||||||
|
return CommanderClient{cmder, new(sync.WaitGroup), id, label}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *CommanderClient) HandleCommands(ctx context.Context, srvAddr string, tc credentials.TransportCredentials) error {
|
||||||
|
cli, err := grpc.NewClient(srvAddr, grpc.WithTransportCredentials(tc))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to connect to gRPC: %w", err)
|
||||||
|
}
|
||||||
|
ccli := proto.NewCommanderClient(cli)
|
||||||
|
bidi, err := ccli.Stream(metadata.NewOutgoingContext(ctx, metadata.MD{"agentid": []string{self.id}, "label": []string{self.label}}))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
wg := new(errgroup.Group)
|
||||||
|
wg.Go(self.recv(bidi))
|
||||||
|
// wg.Go(self.send(bidi))
|
||||||
|
err = wg.Wait()
|
||||||
|
self.wg.Wait()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *CommanderClient) recv(bidi grpc.BidiStreamingClient[proto.FinishedCommand, proto.Command]) func() error {
|
||||||
|
return func() error {
|
||||||
|
for {
|
||||||
|
msg, err := bidi.Recv()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
self.wg.Go(func() {
|
||||||
|
func() error {
|
||||||
|
fc, err := self.cmder.Execute(msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return bidi.Send(fc)
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// func (self *God) send(bidi grpc.BidiStreamingClient[proto.FinishedCommand, proto.Command]) func() error {
|
||||||
|
// return func() error {
|
||||||
|
// return nil
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package commander
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CommandExecutor struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*CommandExecutor) Execute(command *proto.Command) (*proto.FinishedCommand, error) {
|
||||||
|
cmd := exec.Command(command.Command[0], command.Command[1:]...)
|
||||||
|
var (
|
||||||
|
stdin io.WriteCloser
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if command.Stdin != nil {
|
||||||
|
stdin, err = cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stdout, err1 := cmd.StdoutPipe()
|
||||||
|
stderr, err2 := cmd.StderrPipe()
|
||||||
|
if err := errors.Join(err1, err2); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if command.Stdin != nil {
|
||||||
|
io.WriteString(stdin, *command.Stdin)
|
||||||
|
if err := stdin.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eg := new(errgroup.Group)
|
||||||
|
stdoutbuf := new(bytes.Buffer)
|
||||||
|
stderrbuf := new(bytes.Buffer)
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := io.Copy(stdoutbuf, stdout)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
eg.Go(func() error {
|
||||||
|
_, err := io.Copy(stderrbuf, stderr)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := eg.Wait(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &proto.FinishedCommand{
|
||||||
|
Id: command.Id,
|
||||||
|
Status: int32(cmd.ProcessState.ExitCode()),
|
||||||
|
Stdout: stdoutbuf.String(),
|
||||||
|
Stderr: stderrbuf.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ServiceConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Path *string `yaml:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentConfig struct {
|
||||||
|
BackendURL string `yaml:"backend_url"`
|
||||||
|
GRPCURL string `yaml:"grpc_url"`
|
||||||
|
RegistrationToken string `yaml:"registration_token"`
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
CertDir string `yaml:"cert_dir"`
|
||||||
|
Services []ServiceConfig `yaml:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Load(path string) (*AgentConfig, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg AgentConfig
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.CertDir == "" {
|
||||||
|
cfg.CertDir = "/etc/hellreign-agent/certs"
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadFromString(data string) (*AgentConfig, error) {
|
||||||
|
var cfg AgentConfig
|
||||||
|
if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateConfigPath(path string) error {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return fmt.Errorf("config file not found: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logger struct {
|
||||||
|
*slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(debug bool) *Logger {
|
||||||
|
var level slog.Level
|
||||||
|
if debug {
|
||||||
|
level = slog.LevelDebug
|
||||||
|
} else {
|
||||||
|
level = slog.LevelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||||
|
Level: level,
|
||||||
|
})
|
||||||
|
|
||||||
|
return &Logger{
|
||||||
|
Logger: slog.New(handler),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package file
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||||
|
"github.com/hpcloud/tail"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ logsource.LogSource = new(FileLogSource)
|
||||||
|
|
||||||
|
type FileLogSource struct {
|
||||||
|
*tail.Tail
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(filepath string) (fls *FileLogSource, err error) {
|
||||||
|
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
||||||
|
if err := os.WriteFile(filepath, []byte{}, 0600); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t, err := tail.TailFile(filepath, tail.Config{
|
||||||
|
Follow: true,
|
||||||
|
Location: &tail.SeekInfo{
|
||||||
|
Offset: 100,
|
||||||
|
Whence: 2,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return &FileLogSource{t}, nil
|
||||||
|
}
|
||||||
|
func (f *FileLogSource) ReadLine() (string, error) {
|
||||||
|
select {
|
||||||
|
case <-f.Dead():
|
||||||
|
return "", errors.Join(logsource.ErrDead, f.Err())
|
||||||
|
case line := <-f.Lines:
|
||||||
|
return line.Text, line.Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (f *FileLogSource) Close() error {
|
||||||
|
return f.Stop()
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package logsource
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
type LogSource interface {
|
||||||
|
ReadLine() (string, error)
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrDead = errors.New("shouldn't continue to read that")
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package journald
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ logsource.LogSource = new(JournaldLogSource)
|
||||||
|
|
||||||
|
type JournaldLogSource struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
stdout io.ReadCloser
|
||||||
|
stdoutscanner *bufio.Scanner
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLine implements logsource.LogSource.
|
||||||
|
func (j *JournaldLogSource) ReadLine() (string, error) {
|
||||||
|
if j.stdoutscanner.Scan() {
|
||||||
|
return j.stdoutscanner.Text(), nil
|
||||||
|
} else {
|
||||||
|
if j.stdoutscanner.Err() == nil {
|
||||||
|
return "", fmt.Errorf("%w: %s", logsource.ErrDead, io.EOF)
|
||||||
|
}
|
||||||
|
return "", j.stdoutscanner.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (j *JournaldLogSource) Close() error {
|
||||||
|
_ = j.cmd.Process.Signal(syscall.SIGTERM)
|
||||||
|
return j.cmd.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg config.ServiceConfig, logdir string) (*JournaldLogSource, error) {
|
||||||
|
args := make([]string, 0)
|
||||||
|
if cfg.Path != nil {
|
||||||
|
args = append(args, "-u", *cfg.Path)
|
||||||
|
}
|
||||||
|
args = append(args, "-f", "-n", "0", "-o", "short", "--no-pager", "--directory", logdir)
|
||||||
|
cmd := exec.Command("journalctl", args...) //nolint:gosec
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stdoutscanner := bufio.NewScanner(stdout)
|
||||||
|
return &JournaldLogSource{cmd, stdout, stdoutscanner}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package mtls
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadMTLSCredentials loads client certificate and CA certificate for mTLS.
|
||||||
|
func LoadMTLSCredentials(caCertPEM, clientCertPEM, clientKeyPEM []byte) (credentials.TransportCredentials, error) {
|
||||||
|
cert, err := tls.X509KeyPair(clientCertPEM, clientKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load client key pair: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPool := x509.NewCertPool()
|
||||||
|
if !caCertPool.AppendCertsFromPEM(caCertPEM) {
|
||||||
|
return nil, fmt.Errorf("failed to append CA certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
RootCAs: caCertPool,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
|
||||||
|
return credentials.NewTLS(tlsConfig), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadMTLSCredentialsFromFiles loads mTLS credentials from file paths.
|
||||||
|
func LoadMTLSCredentialsFromFiles(caCertPath, clientCertPath, clientKeyPath string) (credentials.TransportCredentials, error) {
|
||||||
|
caCert, err := os.ReadFile(caCertPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read CA cert: %w", err)
|
||||||
|
}
|
||||||
|
clientCert, err := os.ReadFile(clientCertPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read client cert: %w", err)
|
||||||
|
}
|
||||||
|
clientKey, err := os.ReadFile(clientKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read client key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return LoadMTLSCredentials(caCert, clientCert, clientKey)
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package registration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Certs struct {
|
||||||
|
CACertPEM []byte
|
||||||
|
ClientCertPEM []byte
|
||||||
|
ClientKeyPEM []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
CSR string `json:"csr"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterResponse struct {
|
||||||
|
CACert string `json:"ca_cert"`
|
||||||
|
ClientCert string `json:"client_cert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TokenResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateKeyAndCSR generates a new ECDSA private key and CSR for the agent.
|
||||||
|
func GenerateKeyAndCSR(label string) (*ecdsa.PrivateKey, []byte, error) {
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("generate key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
template := x509.CertificateRequest{
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: label,
|
||||||
|
Organization: []string{"HellreigN Agent"},
|
||||||
|
},
|
||||||
|
SignatureAlgorithm: x509.ECDSAWithSHA256,
|
||||||
|
}
|
||||||
|
|
||||||
|
csrDER, err := x509.CreateCertificateRequest(rand.Reader, &template, key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("create csr: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csrPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE REQUEST",
|
||||||
|
Bytes: csrDER,
|
||||||
|
})
|
||||||
|
|
||||||
|
return key, csrPEM, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register sends CSR to backend and receives signed certificates.
|
||||||
|
func Register(backendURL, token string, csrPEM []byte) (*Certs, error) {
|
||||||
|
reqBody := RegisterRequest{CSR: string(csrPEM), Token: token}
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/api/v1/agents/register", backendURL)
|
||||||
|
resp, err := http.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("register request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
var errResp ErrorResponse
|
||||||
|
json.NewDecoder(resp.Body).Decode(&errResp)
|
||||||
|
return nil, fmt.Errorf("registration failed (status %d): %s", resp.StatusCode, errResp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
var regResp RegisterResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Certs{
|
||||||
|
CACertPEM: []byte(regResp.CACert),
|
||||||
|
ClientCertPEM: []byte(regResp.ClientCert),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveCerts saves CA cert, client cert, and client key to the given directory.
|
||||||
|
func SaveCerts(certDir string, certs *Certs, key *ecdsa.PrivateKey) error {
|
||||||
|
if err := os.MkdirAll(certDir, 0700); err != nil {
|
||||||
|
return fmt.Errorf("create cert dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(filepath.Join(certDir, "ca.crt"), certs.CACertPEM, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(certDir, "client.crt"), certs.ClientCertPEM, 0644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "EC PRIVATE KEY",
|
||||||
|
Bytes: keyDER,
|
||||||
|
})
|
||||||
|
if err := os.WriteFile(filepath.Join(certDir, "client.key"), keyPEM, 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCerts loads existing certificates and key from disk.
|
||||||
|
func LoadCerts(certDir string) (*Certs, *ecdsa.PrivateKey, error) {
|
||||||
|
caCert, err := os.ReadFile(filepath.Join(certDir, "ca.crt"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
clientCert, err := os.ReadFile(filepath.Join(certDir, "client.crt"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
clientKeyPEM, err := os.ReadFile(filepath.Join(certDir, "client.key"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
block, _ := pem.Decode(clientKeyPEM)
|
||||||
|
if block == nil {
|
||||||
|
return nil, nil, fmt.Errorf("decode client key")
|
||||||
|
}
|
||||||
|
key, err := x509.ParseECPrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("parse client key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Certs{
|
||||||
|
CACertPEM: caCert,
|
||||||
|
ClientCertPEM: clientCert,
|
||||||
|
}, key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CertsExist checks if all certificate files exist in the directory.
|
||||||
|
func CertsExist(certDir string) bool {
|
||||||
|
files := []string{"ca.crt", "client.crt", "client.key"}
|
||||||
|
for _, f := range files {
|
||||||
|
if _, err := os.Stat(filepath.Join(certDir, f)); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+303
@@ -0,0 +1,303 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/buffer"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/client"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/commander"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/config"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logger"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/file"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/logsource/journald"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/mtls"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent/internal/registration"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
|
"github.com/samber/lo"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfgPath := os.Getenv("CONFIG_FILE")
|
||||||
|
if cfgPath == "" {
|
||||||
|
cfgPath = "/etc/hellreign-agent/config.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := config.Load(cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lgr := logger.New(os.Getenv("IS_DEBUG") == "1")
|
||||||
|
lgr.Debug("Config parsed", "cfg", cfg)
|
||||||
|
|
||||||
|
// Check if certificates already exist (agent was previously registered)
|
||||||
|
if registration.CertsExist(cfg.CertDir) {
|
||||||
|
lgr.Info("Certificates found, skipping registration")
|
||||||
|
} else {
|
||||||
|
if cfg.RegistrationToken == "" {
|
||||||
|
lgr.Error("No registration token provided")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate key and CSR
|
||||||
|
k, csrPEM, err := registration.GenerateKeyAndCSR(cfg.Label)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Error("Failed to generate key and CSR", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
lgr.Info("Generated ECDSA key pair and CSR")
|
||||||
|
|
||||||
|
// Register with backend
|
||||||
|
certs, err := registration.Register(cfg.BackendURL, cfg.RegistrationToken, csrPEM)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Error("Failed to register", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
lgr.Info("Successfully registered, received certificates")
|
||||||
|
|
||||||
|
// Save certificates
|
||||||
|
if err := registration.SaveCerts(cfg.CertDir, certs, k); err != nil {
|
||||||
|
lgr.Error("Failed to save certificates", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
lgr.Info("Certificates saved", "cert_dir", cfg.CertDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
creds, err := mtls.LoadMTLSCredentialsFromFiles(
|
||||||
|
cfg.CertDir+"/ca.crt",
|
||||||
|
cfg.CertDir+"/client.crt",
|
||||||
|
cfg.CertDir+"/client.key",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Error("Failed to load TLS credentials", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize log buffer for offline storage
|
||||||
|
dbPath := getEnvOrDefault("BUFFER_DB", "/var/lib/hellreign-agent/agent_buffer.db")
|
||||||
|
logBuf, err := buffer.NewLogBuffer(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Error("Failed to create log buffer", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer func() { _ = logBuf.Close() }()
|
||||||
|
lgr.Info("Log buffer initialized", "path", dbPath)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
wg := &errgroup.Group{}
|
||||||
|
|
||||||
|
grpcAddr := cfg.GRPCURL
|
||||||
|
if grpcAddr == "" {
|
||||||
|
grpcAddr = cfg.BackendURL
|
||||||
|
}
|
||||||
|
grpcAddr = strings.TrimPrefix(grpcAddr, "http://")
|
||||||
|
grpcAddr = strings.TrimPrefix(grpcAddr, "https://")
|
||||||
|
// Start command executor
|
||||||
|
wg.Go(func() error {
|
||||||
|
cmdexe := new(commander.CommandExecutor)
|
||||||
|
ccli := client.New(cmdexe, cfg.Label, cfg.Label)
|
||||||
|
return ccli.HandleCommands(ctx, grpcAddr, creds)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Start log collectors
|
||||||
|
if len(cfg.Services) > 0 {
|
||||||
|
wg.Go(func() error {
|
||||||
|
conn, err := grpc.NewClient(grpcAddr, grpc.WithTransportCredentials(creds))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to connect to gRPC: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
ccli := proto.NewCollectorClient(conn)
|
||||||
|
|
||||||
|
svcWg := new(errgroup.Group)
|
||||||
|
for _, svc := range cfg.Services {
|
||||||
|
svc := svc
|
||||||
|
var src logsource.LogSource
|
||||||
|
switch svc.Type {
|
||||||
|
case "journald":
|
||||||
|
src, err = journald.New(svc, os.Getenv("JOURNALD_LOGDIR"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create journald source %q: %w", svc.Name, err)
|
||||||
|
}
|
||||||
|
case "file":
|
||||||
|
if svc.Path == nil {
|
||||||
|
return fmt.Errorf("path is required for file log source %q", svc.Name)
|
||||||
|
}
|
||||||
|
src, err = file.New(*svc.Path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create file source %q: %w", svc.Name, err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown log source type %q for service %q", svc.Type, svc.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
svcWg.Go(func() error {
|
||||||
|
lgr.Info("Starting log stream", "service", svc.Name)
|
||||||
|
|
||||||
|
// First, flush any buffered logs from offline period
|
||||||
|
if err := flushBufferedLogs(ctx, ccli, logBuf, svc.Name, cfg.Label, cfg.RegistrationToken, lgr); err != nil {
|
||||||
|
lgr.Error("Failed to flush buffered logs", "service", svc.Name, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
scli, err := ccli.Stream(
|
||||||
|
metadata.NewOutgoingContext(ctx, metadata.MD{
|
||||||
|
"whoami": []string{cfg.Label},
|
||||||
|
"service": []string{svc.Name},
|
||||||
|
"token": []string{cfg.RegistrationToken},
|
||||||
|
"services": lo.Map(cfg.Services, func(item config.ServiceConfig, _ int) string {
|
||||||
|
return item.Name
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := src.ReadLine()
|
||||||
|
if err != nil {
|
||||||
|
lgr.Error("ReadLine error", "service", svc.Name, "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scli.Send(&proto.CollectorRequest{
|
||||||
|
Message: line,
|
||||||
|
}); err != nil {
|
||||||
|
// Connection failed, buffer the log
|
||||||
|
lgr.Warn("Send failed, buffering log", "service", svc.Name, "err", err)
|
||||||
|
if storeErr := logBuf.Store(svc.Name, line); storeErr != nil {
|
||||||
|
lgr.Error("Failed to buffer log", "service", svc.Name, "err", storeErr)
|
||||||
|
}
|
||||||
|
// Try to reconnect
|
||||||
|
if reconnectErr := reconnectStream(ctx, &scli, ccli, svc.Name, cfg.Label, cfg.RegistrationToken, logBuf, lgr); reconnectErr != nil {
|
||||||
|
return reconnectErr
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return svcWg.Wait()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := wg.Wait(); err != nil {
|
||||||
|
lgr.Error("Agent dead", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvOrDefault(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushBufferedLogs sends any buffered logs to the server
|
||||||
|
func flushBufferedLogs(
|
||||||
|
ctx context.Context,
|
||||||
|
ccli proto.CollectorClient,
|
||||||
|
logBuf *buffer.LogBuffer,
|
||||||
|
service, agentName, token string,
|
||||||
|
lgr *logger.Logger,
|
||||||
|
) error {
|
||||||
|
count, err := logBuf.Count()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lgr.Info("Flushing buffered logs", "service", service, "count", count)
|
||||||
|
|
||||||
|
scli, err := ccli.Stream(
|
||||||
|
metadata.NewOutgoingContext(ctx, metadata.MD{
|
||||||
|
"whoami": []string{agentName},
|
||||||
|
"service": []string{service},
|
||||||
|
"token": []string{token},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create stream for flush: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const batchSize = 100
|
||||||
|
var deletedIDs []int64
|
||||||
|
|
||||||
|
for {
|
||||||
|
logs, err := logBuf.GetPending(batchSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(logs) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, logEntry := range logs {
|
||||||
|
if err := scli.Send(&proto.CollectorRequest{Message: logEntry.Message}); err != nil {
|
||||||
|
lgr.Error("Failed to send buffered log", "service", service, "err", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deletedIDs = append(deletedIDs, logEntry.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete successfully sent logs
|
||||||
|
if err := logBuf.DeleteBatch(deletedIDs); err != nil {
|
||||||
|
lgr.Error("Failed to delete sent logs from buffer", "service", service, "err", err)
|
||||||
|
}
|
||||||
|
deletedIDs = deletedIDs[:0]
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = scli.CloseAndRecv()
|
||||||
|
lgr.Info("Buffer flush complete", "service", service)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnectStream attempts to recreate a gRPC stream connection
|
||||||
|
func reconnectStream(
|
||||||
|
ctx context.Context,
|
||||||
|
scli *grpc.ClientStreamingClient[proto.CollectorRequest, proto.CollectorResponse],
|
||||||
|
ccli proto.CollectorClient,
|
||||||
|
service, agentName, token string,
|
||||||
|
buf *buffer.LogBuffer,
|
||||||
|
lgr *logger.Logger,
|
||||||
|
) error {
|
||||||
|
lgr.Info("Attempting to reconnect stream...", "service", service)
|
||||||
|
|
||||||
|
// Try up to 5 times with exponential backoff
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
time.Sleep(time.Duration(i+1) * time.Second)
|
||||||
|
|
||||||
|
newCli, err := ccli.Stream(
|
||||||
|
metadata.NewOutgoingContext(ctx, metadata.MD{
|
||||||
|
"whoami": []string{agentName},
|
||||||
|
"service": []string{service},
|
||||||
|
"token": []string{token},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
lgr.Warn("Reconnect attempt failed", "service", service, "attempt", i+1, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
*scli = newCli
|
||||||
|
lgr.Info("Stream reconnected successfully", "service", service)
|
||||||
|
return flushBufferedLogs(ctx, ccli, buf, service, agentName, token, lgr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to reconnect after 5 attempts for service %s", service)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
version: "2"
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
tests: false
|
||||||
|
build-tags:
|
||||||
|
- integration
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable:
|
||||||
|
- errcheck
|
||||||
|
- errname
|
||||||
|
- govet
|
||||||
|
- staticcheck
|
||||||
|
- gosec
|
||||||
|
- nilerr
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
|
- goimports
|
||||||
|
- golines
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/docs"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/config"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/handlers"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
swaggerFiles "github.com/swaggo/files"
|
||||||
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @securityDefinitions.apikey Bearer
|
||||||
|
// @in header
|
||||||
|
// @name Authorization
|
||||||
|
// @description Type "Bearer" followed by a space and the JWT token.
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg_path, ok := os.LookupEnv("CONFIG_FILE")
|
||||||
|
if !ok {
|
||||||
|
cfg_path = "/etc/hellreign/config.yml"
|
||||||
|
}
|
||||||
|
cfg, err := config.ImportSettings(cfg_path)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Err loading config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := storage.Open(cfg.Database.Token_db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Err opening database: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
h := handlers.New(db)
|
||||||
|
|
||||||
|
// Initialize registration tokens table
|
||||||
|
if err := h.Repo.InitRegistrationTokens(); err != nil {
|
||||||
|
log.Printf("Warning: failed to initialize registration tokens table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize jobs table
|
||||||
|
jobRepo := repository.NewJobRepository(db)
|
||||||
|
if err := jobRepo.Init(context.Background()); err != nil {
|
||||||
|
log.Printf("Warning: failed to initialize jobs table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize ClickHouse and log repository
|
||||||
|
logRepo := repository.NewLogRepository()
|
||||||
|
if cfg.Database.Clickhouse_host != "" {
|
||||||
|
go func() {
|
||||||
|
db, err := storage.OpenClickHouseWithRetry(storage.ClickHouseConfig{
|
||||||
|
Host: cfg.Database.Clickhouse_host,
|
||||||
|
User: cfg.Database.Clickhouse_user,
|
||||||
|
Password: cfg.Database.Clickhouse_password,
|
||||||
|
Database: cfg.Database.Clickhouse_database,
|
||||||
|
}, 10, 5*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: ClickHouse connection failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Println("ClickHouse connected successfully")
|
||||||
|
logRepo.SetDB(db)
|
||||||
|
if err := logRepo.Init(context.Background()); err != nil {
|
||||||
|
log.Printf("Warning: Failed to initialize logs table: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Collector gRPC service
|
||||||
|
coll := collector.New(logRepo)
|
||||||
|
|
||||||
|
cmdr := commander.New(jobRepo)
|
||||||
|
|
||||||
|
// Initialize script interpreter repository and service
|
||||||
|
scriptRepo := repository.NewScriptInterpreterRepo(db)
|
||||||
|
if err := scriptRepo.Init(context.Background()); err != nil {
|
||||||
|
log.Printf("Warning: failed to initialize script interpreters table: %v", err)
|
||||||
|
}
|
||||||
|
scriptSvc := service.NewScriptService(scriptRepo)
|
||||||
|
scriptHandlers := handlers.NewScriptHandlers(scriptSvc, cmdr)
|
||||||
|
jobsHandlers := handlers.NewJobsHandlers(cmdr, scriptSvc)
|
||||||
|
|
||||||
|
agents := handlers.NewAgentsGroup(h, coll)
|
||||||
|
auth := handlers.AuthGroup{Handlers: h}
|
||||||
|
agentReg := handlers.NewAgentRegistrationGroup(h)
|
||||||
|
agentDeploy := handlers.NewAgentDeployGroup(h)
|
||||||
|
|
||||||
|
// Create admin user from config if not exists
|
||||||
|
if cfg.Admin.Admin_login != "" && cfg.Admin.Admin_password != "" {
|
||||||
|
if !h.Repo.ExistsByLogin(cfg.Admin.Admin_login) {
|
||||||
|
_, err := h.Repo.CreateToken(repository.TokenCreate{
|
||||||
|
Name: cfg.Admin.Admin_name,
|
||||||
|
LastName: cfg.Admin.Admin_last_name,
|
||||||
|
Login: cfg.Admin.Admin_login,
|
||||||
|
Password: cfg.Admin.Admin_password,
|
||||||
|
PermissionView: true,
|
||||||
|
PermissionManage: true,
|
||||||
|
PermissionAdmin: true,
|
||||||
|
IsActive: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: failed to create admin user: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Admin user created from config")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Ensure existing admin is activated
|
||||||
|
if err := h.Repo.ActivateUserByLogin(cfg.Admin.Admin_login); err != nil {
|
||||||
|
log.Printf("Warning: failed to activate admin user: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("Admin user activated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.Default()
|
||||||
|
docs.SwaggerInfo.BasePath = "/api/v1"
|
||||||
|
docs.SwaggerInfo.Title = "HellreigN"
|
||||||
|
docs.SwaggerInfo.Version = "1.0"
|
||||||
|
docs.SwaggerInfo.Description = "API for HellreigN"
|
||||||
|
docs.SwaggerInfo.Schemes = []string{"http"}
|
||||||
|
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
|
||||||
|
|
||||||
|
v1 := router.Group("/api/v1")
|
||||||
|
{
|
||||||
|
// Auth routes (public)
|
||||||
|
authGroup := v1.Group("/auth")
|
||||||
|
{
|
||||||
|
authGroup.POST("/login", auth.Login)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth token management (requires auth)
|
||||||
|
authTokenGroup := v1.Group("/auth")
|
||||||
|
authTokenGroup.Use(auth.AuthMiddleware())
|
||||||
|
{
|
||||||
|
authTokenGroup.POST("/token", handlers.RequireAdmin(), auth.CreateToken)
|
||||||
|
authTokenGroup.GET("/validate", auth.ValidateToken)
|
||||||
|
authTokenGroup.GET("/tokens", handlers.RequireAdmin(), auth.ListTokens)
|
||||||
|
authTokenGroup.DELETE("/token", auth.DeleteMyToken)
|
||||||
|
authTokenGroup.DELETE("/tokens/:login", handlers.RequireAdmin(), auth.DeleteToken)
|
||||||
|
|
||||||
|
// User management (admin only) - Full CRUD
|
||||||
|
authTokenGroup.GET("/users/:login", handlers.RequireAdmin(), auth.GetUser)
|
||||||
|
authTokenGroup.PUT("/users/:login", handlers.RequireAdmin(), auth.UpdateUser)
|
||||||
|
authTokenGroup.PUT("/users/:login/permissions", handlers.RequireAdmin(), auth.UpdateUserPermissions)
|
||||||
|
authTokenGroup.PUT("/users/:login/password", handlers.RequireAdmin(), auth.ResetUserPassword)
|
||||||
|
|
||||||
|
// User activation management (admin only)
|
||||||
|
authTokenGroup.POST("/users/:login/activate", handlers.RequireAdmin(), auth.ActivateUser)
|
||||||
|
authTokenGroup.POST("/users/:login/deactivate", handlers.RequireAdmin(), auth.DeactivateUser)
|
||||||
|
authTokenGroup.GET("/users/inactive", handlers.RequireAdmin(), auth.ListInactiveUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agents (requires manage_agent permission)
|
||||||
|
agentsGroup := v1.Group("/agents")
|
||||||
|
agentsGroup.Use(auth.AuthMiddleware(), handlers.RequireManageAgent())
|
||||||
|
{
|
||||||
|
agentsGroup.GET("", agents.List)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Jobs (requires admin permission)
|
||||||
|
jobsGroup := v1.Group("/jobs")
|
||||||
|
jobsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
|
||||||
|
{
|
||||||
|
jobsGroup.POST("", jobsHandlers.AddJob)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent registration
|
||||||
|
agentRegGroup := v1.Group("/agents")
|
||||||
|
{
|
||||||
|
agentRegGroup.POST("/register", agentReg.Register)
|
||||||
|
}
|
||||||
|
agentRegTokenGroup := v1.Group("/agents")
|
||||||
|
agentRegTokenGroup.Use(auth.AuthMiddleware(), handlers.RequireManageAgent())
|
||||||
|
{
|
||||||
|
agentRegTokenGroup.POST("/register-token", agentReg.CreateRegistrationToken)
|
||||||
|
agentRegTokenGroup.POST("/deploy", agentDeploy.DeployAgents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logs (requires view permission)
|
||||||
|
logsGroup := v1.Group("/logs")
|
||||||
|
logsGroup.Use(auth.AuthMiddleware(), handlers.RequireView())
|
||||||
|
{
|
||||||
|
// Mock logs endpoint (always available, no ClickHouse required)
|
||||||
|
mockLogHandlers := handlers.NewLogHandlers(nil)
|
||||||
|
logsGroup.GET("/mock", mockLogHandlers.GetMockLogs)
|
||||||
|
|
||||||
|
// ClickHouse log handlers (always registered, work when ClickHouse connects)
|
||||||
|
logHandlers := handlers.NewLogHandlers(logRepo)
|
||||||
|
logsGroup.POST("", logHandlers.Insert)
|
||||||
|
logsGroup.POST("/batch", logHandlers.InsertBatch)
|
||||||
|
logsGroup.GET("", logHandlers.Search)
|
||||||
|
logsGroup.GET("/services", logHandlers.GetServices)
|
||||||
|
logsGroup.GET("/agents", logHandlers.GetAgents)
|
||||||
|
logsGroup.GET("/levels", logHandlers.GetLevels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scripts (requires admin permission)
|
||||||
|
scriptsGroup := v1.Group("/scripts")
|
||||||
|
scriptsGroup.Use(auth.AuthMiddleware(), handlers.RequireAdmin())
|
||||||
|
{
|
||||||
|
scriptsGroup.POST("/run", scriptHandlers.RunScript)
|
||||||
|
scriptsGroup.GET("/interpreters", scriptHandlers.ListInterpreters)
|
||||||
|
scriptsGroup.POST("/interpreters", scriptHandlers.CreateInterpreter)
|
||||||
|
scriptsGroup.GET("/interpreters/:id", scriptHandlers.GetInterpreter)
|
||||||
|
scriptsGroup.PUT("/interpreters/:id", scriptHandlers.UpdateInterpreter)
|
||||||
|
scriptsGroup.DELETE("/interpreters/:id", scriptHandlers.DeleteInterpreter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start gRPC server with mTLS in background
|
||||||
|
grpcPort := os.Getenv("GRPC_PORT")
|
||||||
|
if grpcPort == "" {
|
||||||
|
grpcPort = "9001"
|
||||||
|
}
|
||||||
|
|
||||||
|
certDir := os.Getenv("SSL_CERT_DIR")
|
||||||
|
if certDir == "" {
|
||||||
|
certDir = "/var/lib/hellreign/ssl"
|
||||||
|
}
|
||||||
|
|
||||||
|
certFile := certDir + "/server.crt"
|
||||||
|
keyFile := certDir + "/server.key"
|
||||||
|
caFile := certDir + "/ca.crt"
|
||||||
|
|
||||||
|
// Load server cert
|
||||||
|
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load server cert: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load CA cert for client verification
|
||||||
|
caCert, err := os.ReadFile(caFile)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load CA cert: %v", err)
|
||||||
|
}
|
||||||
|
caCertPool := x509.NewCertPool()
|
||||||
|
caCertPool.AppendCertsFromPEM(caCert)
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
ClientCAs: caCertPool,
|
||||||
|
ClientAuth: tls.RequireAndVerifyClientCert,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsConfig)))
|
||||||
|
proto.RegisterCommanderServer(grpcServer, cmdr)
|
||||||
|
proto.RegisterCollectorServer(grpcServer, coll)
|
||||||
|
|
||||||
|
lis, err := net.Listen("tcp", ":"+grpcPort)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to listen on gRPC port %s: %v", grpcPort, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g, ctx := errgroup.WithContext(context.Background())
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
log.Printf("gRPC server starting on port %s with mTLS", grpcPort)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- grpcServer.Serve(lis) }()
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
grpcServer.GracefulStop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
g.Go(func() error {
|
||||||
|
log.Printf("HTTP server starting on :8080")
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() { errCh <- router.Run(":8080") }()
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
return err
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := g.Wait(); err != nil {
|
||||||
|
log.Fatalf("Server error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM golang:1.26.1 as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY backend/ backend/
|
||||||
|
COPY proto/ proto/
|
||||||
|
WORKDIR /app/backend
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
ENV GIN_MODE=release
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
go mod download && \
|
||||||
|
go build -ldflags "-s -w" -o backend ./cmd/main.go
|
||||||
|
|
||||||
|
FROM alpine:3.23.0
|
||||||
|
|
||||||
|
RUN apk add --no-cache curl openssl bash ansible
|
||||||
|
|
||||||
|
COPY --from=builder /app/backend/backend .
|
||||||
|
COPY --from=builder /app/backend/scripts /etc/hellreign/scripts
|
||||||
|
RUN chmod +x /etc/hellreign/scripts/generate-certs.sh
|
||||||
|
|
||||||
|
# Generate certificates on container start
|
||||||
|
ENTRYPOINT ["/bin/sh", "-c", "/etc/hellreign/scripts/generate-certs.sh ${SSL_CERT_DIR:-/var/lib/hellreign/ssl} && exec ./backend"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
|||||||
|
module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto v0.0.0-20260403210401-a6212c89fc0e
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/swaggo/files v1.0.1
|
||||||
|
github.com/swaggo/gin-swagger v1.6.1
|
||||||
|
github.com/swaggo/swag v1.16.6
|
||||||
|
golang.org/x/crypto v0.49.0
|
||||||
|
golang.org/x/sync v0.20.0
|
||||||
|
google.golang.org/grpc v1.80.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.48.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||||
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.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.13 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||||
|
github.com/go-faster/city v1.0.1 // indirect
|
||||||
|
github.com/go-faster/errors v0.7.1 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.21.5 // indirect
|
||||||
|
github.com/go-openapi/spec v0.22.4 // indirect
|
||||||
|
github.com/go-openapi/swag/conv v0.25.5 // indirect
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||||
|
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
|
||||||
|
github.com/go-openapi/swag/loading v0.25.5 // indirect
|
||||||
|
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
|
||||||
|
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
|
||||||
|
github.com/go-openapi/swag/yamlutils v0.25.5 // 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.2 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.3 // 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/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/paulmach/orb v0.12.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25 // 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/segmentio/asm v1.2.1 // indirect
|
||||||
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.41.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/arch v0.25.0 // indirect
|
||||||
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
|
golang.org/x/net v0.52.0 // indirect
|
||||||
|
golang.org/x/sys v0.42.0 // indirect
|
||||||
|
golang.org/x/text v0.35.0 // indirect
|
||||||
|
golang.org/x/tools v0.43.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
|
modernc.org/libc v1.70.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto => ../proto
|
||||||
+304
@@ -0,0 +1,304 @@
|
|||||||
|
github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM=
|
||||||
|
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 h1:9pxs5pRwIvhni5BDRPn/n5A8DeUod5TnBaeulFBX8EQ=
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
||||||
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
|
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||||
|
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.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
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.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
|
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
|
||||||
|
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
|
||||||
|
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||||
|
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||||
|
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/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||||
|
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||||
|
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||||
|
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||||
|
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||||
|
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
|
||||||
|
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
|
||||||
|
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
|
||||||
|
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
|
||||||
|
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||||
|
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
|
||||||
|
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||||
|
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||||
|
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
|
||||||
|
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
|
||||||
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
|
||||||
|
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
|
||||||
|
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
|
||||||
|
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
|
||||||
|
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
|
||||||
|
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
|
||||||
|
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
|
||||||
|
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
|
||||||
|
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
|
||||||
|
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
|
||||||
|
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
|
||||||
|
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||||
|
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||||
|
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.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||||
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
|
github.com/goccy/go-json v0.10.6/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/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
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/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
|
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||||
|
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
|
||||||
|
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
|
||||||
|
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/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
|
||||||
|
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||||
|
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||||
|
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-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||||
|
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||||
|
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||||
|
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||||
|
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
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/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||||
|
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||||
|
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||||
|
github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw=
|
||||||
|
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||||
|
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||||
|
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||||
|
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/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
|
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||||
|
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||||
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
|
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||||
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||||
|
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.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
|
||||||
|
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
|
||||||
|
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
|
||||||
|
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||||
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
|
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||||
|
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||||
|
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||||
|
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||||
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
|
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/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-20180628173108-788fd7840127/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/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=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
|
||||||
|
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Executor handles running Ansible playbooks
|
||||||
|
type Executor struct {
|
||||||
|
workDir string
|
||||||
|
grpcServerHost string
|
||||||
|
grpcServerPort string
|
||||||
|
backendURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecutorConfig holds configuration for the Executor
|
||||||
|
type ExecutorConfig struct {
|
||||||
|
WorkDir string
|
||||||
|
GRPCServerHost string
|
||||||
|
GRPCServerPort string
|
||||||
|
BackendURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExecutor creates a new Ansible executor
|
||||||
|
func NewExecutor(cfg ExecutorConfig) *Executor {
|
||||||
|
return &Executor{
|
||||||
|
workDir: cfg.WorkDir,
|
||||||
|
grpcServerHost: cfg.GRPCServerHost,
|
||||||
|
grpcServerPort: cfg.GRPCServerPort,
|
||||||
|
backendURL: cfg.BackendURL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployResult holds the result of a deployment
|
||||||
|
type DeployResult struct {
|
||||||
|
Host string
|
||||||
|
Success bool
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkDir returns the work directory path
|
||||||
|
func (e *Executor) WorkDir() string {
|
||||||
|
return e.workDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy runs Ansible playbook for the given inventory
|
||||||
|
func (e *Executor) Deploy(ctx context.Context, inventoryPath string, deployType string) ([]DeployResult, error) {
|
||||||
|
playbookName := "binary_deploy.yml"
|
||||||
|
if deployType == "docker" {
|
||||||
|
playbookName = "docker_deploy.yml"
|
||||||
|
}
|
||||||
|
|
||||||
|
playbookPath := filepath.Join(e.workDir, playbookName)
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "ansible-playbook",
|
||||||
|
"-i", inventoryPath,
|
||||||
|
"-e", fmt.Sprintf("backend_url=%s", e.backendURL),
|
||||||
|
playbookPath,
|
||||||
|
)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
|
||||||
|
runErr := cmd.Run()
|
||||||
|
|
||||||
|
// Parse results per host (simplified - returns single result for all)
|
||||||
|
return []DeployResult{
|
||||||
|
{
|
||||||
|
Host: "all",
|
||||||
|
Success: runErr == nil,
|
||||||
|
Stdout: stdout.String(),
|
||||||
|
Stderr: stderr.String(),
|
||||||
|
Err: runErr,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployParallel runs Ansible playbook for multiple inventories in parallel
|
||||||
|
func (e *Executor) DeployParallel(ctx context.Context, inventoryPaths []string, deployType string) (map[string][]DeployResult, error) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make(map[string][]DeployResult)
|
||||||
|
errCh := make(chan error, len(inventoryPaths))
|
||||||
|
|
||||||
|
for _, path := range inventoryPaths {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(p string) {
|
||||||
|
defer wg.Done()
|
||||||
|
res, err := e.Deploy(ctx, p, deployType)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
results[p] = res
|
||||||
|
}(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
|
||||||
|
// Collect errors
|
||||||
|
var errs []error
|
||||||
|
for err := range errCh {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return results, fmt.Errorf("some deployments failed: %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePlaybook writes a playbook to the work directory
|
||||||
|
func (e *Executor) WritePlaybook(name string, content string) error {
|
||||||
|
path := filepath.Join(e.workDir, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, []byte(content), 0644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteAllPlaybooks writes all playbooks to the work directory
|
||||||
|
func (e *Executor) WriteAllPlaybooks() error {
|
||||||
|
if err := e.WritePlaybook("binary_deploy.yml", BinaryDeployPlaybook); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.WritePlaybook("docker_deploy.yml", DockerDeployPlaybook)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package ansible
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InventoryHost represents a single host in the inventory
|
||||||
|
type InventoryHost struct {
|
||||||
|
Name string
|
||||||
|
IP string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
AuthMethod string
|
||||||
|
SSHKey string
|
||||||
|
Password string
|
||||||
|
DeployType string
|
||||||
|
Token string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventory represents an Ansible inventory file
|
||||||
|
type Inventory struct {
|
||||||
|
Hosts []InventoryHost
|
||||||
|
}
|
||||||
|
|
||||||
|
const inventoryTemplateText = `{{ range .Hosts }}
|
||||||
|
{{ .Name }} ansible_host={{ .IP }} ansible_port={{ .Port }} ansible_user={{ .User }} ansible_connection=ssh
|
||||||
|
{{ if eq .AuthMethod "key" }}ansible_ssh_private_key_file={{ .SSHKey }}{{ end }}
|
||||||
|
{{ if eq .AuthMethod "password" }}ansible_ssh_pass={{ .Password }}{{ end }}
|
||||||
|
deploy_type={{ .DeployType }}
|
||||||
|
agent_token={{ .Token }}
|
||||||
|
agent_label={{ .Name }}
|
||||||
|
|
||||||
|
{{ end }}`
|
||||||
|
|
||||||
|
// GenerateInventory generates an Ansible inventory file from the given hosts
|
||||||
|
func GenerateInventory(hosts []InventoryHost, outputPath string) error {
|
||||||
|
tmpl, err := template.New("inventory").Parse(inventoryTemplateText)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse inventory template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
dir := filepath.Dir(outputPath)
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create inventory directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create inventory file: %w", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := tmpl.Execute(file, Inventory{Hosts: hosts}); err != nil {
|
||||||
|
return fmt.Errorf("failed to execute inventory template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
package ansible
|
||||||
|
|
||||||
|
// BinaryDeployPlaybook returns the Ansible playbook for binary deployment
|
||||||
|
const BinaryDeployPlaybook = `---
|
||||||
|
- name: Deploy HellreigN Agent (Binary)
|
||||||
|
hosts: all
|
||||||
|
become: yes
|
||||||
|
vars:
|
||||||
|
agent_label: "{{ agent_label }}"
|
||||||
|
agent_token: "{{ agent_token }}"
|
||||||
|
backend_url: "{{ backend_url }}"
|
||||||
|
install_dir: /opt/hellreign
|
||||||
|
bin_name: hellreign-agent
|
||||||
|
service_name: hellreign-agent
|
||||||
|
cert_dir: "{{ install_dir }}/certs"
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Create installation directory
|
||||||
|
file:
|
||||||
|
path: "{{ install_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create certificates directory
|
||||||
|
file:
|
||||||
|
path: "{{ cert_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Download HellreigN Agent binary
|
||||||
|
get_url:
|
||||||
|
url: "https://gitea.d3m0k1d.ru/d3m0k1d/HellreigN/releases/latest/download/{{ bin_name }}"
|
||||||
|
dest: "{{ install_dir }}/{{ bin_name }}"
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Create agent configuration
|
||||||
|
copy:
|
||||||
|
content: |
|
||||||
|
backend_url: "{{ backend_url }}"
|
||||||
|
label: "{{ agent_label }}"
|
||||||
|
registration_token: "{{ agent_token }}"
|
||||||
|
cert_dir: "{{ cert_dir }}"
|
||||||
|
dest: "{{ install_dir }}/config.yml"
|
||||||
|
mode: '0644'
|
||||||
|
|
||||||
|
- name: Create systemd service file
|
||||||
|
copy:
|
||||||
|
content: |
|
||||||
|
[Unit]
|
||||||
|
Description=HellreigN Agent
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart={{ install_dir }}/{{ bin_name }}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
Environment=CONFIG_FILE={{ install_dir }}/config.yml
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
dest: /etc/systemd/system/{{ service_name }}.service
|
||||||
|
mode: '0644'
|
||||||
|
|
||||||
|
- name: Reload systemd daemon
|
||||||
|
systemd:
|
||||||
|
daemon_reload: yes
|
||||||
|
|
||||||
|
- name: Enable and start HellreigN Agent service
|
||||||
|
systemd:
|
||||||
|
name: "{{ service_name }}"
|
||||||
|
enabled: yes
|
||||||
|
state: started
|
||||||
|
`
|
||||||
|
|
||||||
|
// DockerDeployPlaybook returns the Ansible playbook for Docker deployment
|
||||||
|
const DockerDeployPlaybook = `---
|
||||||
|
- name: Deploy HellreigN Agent (Docker)
|
||||||
|
hosts: all
|
||||||
|
become: yes
|
||||||
|
vars:
|
||||||
|
agent_label: "{{ agent_label }}"
|
||||||
|
agent_token: "{{ agent_token }}"
|
||||||
|
backend_url: "{{ backend_url }}"
|
||||||
|
container_name: hellreign-agent-{{ agent_label }}
|
||||||
|
image: "gitea.d3m0k1d.ru/d3m0k1d/hellreign-agent:latest"
|
||||||
|
cert_dir: /etc/hellreign-agent/certs
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: Install Docker (if not present)
|
||||||
|
block:
|
||||||
|
- name: Check if Docker is installed
|
||||||
|
command: docker --version
|
||||||
|
register: docker_check
|
||||||
|
ignore_errors: yes
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Install Docker
|
||||||
|
shell: |
|
||||||
|
curl -fsSL https://get.docker.com | sh
|
||||||
|
when: docker_check.rc != 0
|
||||||
|
|
||||||
|
- name: Create certificates directory
|
||||||
|
file:
|
||||||
|
path: "{{ cert_dir }}"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
|
- name: Pull HellreigN Agent image
|
||||||
|
community.docker.docker_image:
|
||||||
|
name: "{{ image }}"
|
||||||
|
source: pull
|
||||||
|
|
||||||
|
- name: Create agent configuration
|
||||||
|
copy:
|
||||||
|
content: |
|
||||||
|
backend_url: "{{ backend_url }}"
|
||||||
|
label: "{{ agent_label }}"
|
||||||
|
registration_token: "{{ agent_token }}"
|
||||||
|
cert_dir: "{{ cert_dir }}"
|
||||||
|
dest: "{{ cert_dir }}/config.yml"
|
||||||
|
mode: '0644'
|
||||||
|
|
||||||
|
- name: Create and run HellreigN Agent container
|
||||||
|
community.docker.docker_container:
|
||||||
|
name: "{{ container_name }}"
|
||||||
|
image: "{{ image }}"
|
||||||
|
state: started
|
||||||
|
restart_policy: always
|
||||||
|
volumes:
|
||||||
|
- "{{ cert_dir }}:/etc/hellreign-agent/certs"
|
||||||
|
env:
|
||||||
|
CONFIG_FILE: /etc/hellreign-agent/certs/config.yml
|
||||||
|
`
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package ansible
|
||||||
|
|
||||||
|
const BaseInvTemplate = `
|
||||||
|
|
||||||
|
`
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ImportSettings(path string) (*HellreigN, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
var cfg HellreigN
|
||||||
|
|
||||||
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
type HellreigN struct {
|
||||||
|
Database Databases `yaml:"database"`
|
||||||
|
Admin Admin `yaml:"admin"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Databases struct {
|
||||||
|
Token_db string `yaml:"token_db"`
|
||||||
|
Clickhouse_host string `yaml:"clickhouse_host"`
|
||||||
|
Clickhouse_user string `yaml:"clickhouse_user"`
|
||||||
|
Clickhouse_password string `yaml:"clickhouse_password"`
|
||||||
|
Clickhouse_database string `yaml:"clickhouse_database"`
|
||||||
|
}
|
||||||
|
type Admin struct {
|
||||||
|
Admin_name string `yaml:"admin_name"`
|
||||||
|
Admin_last_name string `yaml:"admin_last_name"`
|
||||||
|
Admin_login string `yaml:"admin_login"`
|
||||||
|
Admin_password string `yaml:"admin_password"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package collector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Collector struct {
|
||||||
|
proto.UnimplementedCollectorServer
|
||||||
|
logRepo *repository.LogRepository
|
||||||
|
agents map[string]*Agent
|
||||||
|
mu sync.RWMutex
|
||||||
|
batchSize int
|
||||||
|
flushInterval time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type Agent struct {
|
||||||
|
ID string
|
||||||
|
Label string
|
||||||
|
Services []string
|
||||||
|
ConnectedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(logRepo *repository.LogRepository) *Collector {
|
||||||
|
return &Collector{
|
||||||
|
logRepo: logRepo,
|
||||||
|
agents: make(map[string]*Agent),
|
||||||
|
batchSize: 100,
|
||||||
|
flushInterval: 2 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Stream(stream proto.Collector_StreamServer) error {
|
||||||
|
md, ok := metadata.FromIncomingContext(stream.Context())
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no metadata in context")
|
||||||
|
}
|
||||||
|
|
||||||
|
whoamiVals := md["whoami"]
|
||||||
|
if len(whoamiVals) == 0 {
|
||||||
|
return fmt.Errorf("whoami metadata missing")
|
||||||
|
}
|
||||||
|
agentName := whoamiVals[0]
|
||||||
|
|
||||||
|
serviceVals := md["service"]
|
||||||
|
if len(serviceVals) == 0 {
|
||||||
|
return fmt.Errorf("service metadata missing")
|
||||||
|
}
|
||||||
|
service := serviceVals[0]
|
||||||
|
|
||||||
|
servicesVals := md["services"]
|
||||||
|
var services []string
|
||||||
|
if len(servicesVals) > 0 {
|
||||||
|
services = servicesVals
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register agent
|
||||||
|
c.mu.Lock()
|
||||||
|
c.agents[agentName] = &Agent{
|
||||||
|
ID: agentName,
|
||||||
|
Label: agentName,
|
||||||
|
Services: services,
|
||||||
|
ConnectedAt: time.Now(),
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
c.mu.Lock()
|
||||||
|
delete(c.agents, agentName)
|
||||||
|
c.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("Agent %s connected, streaming logs for service: %s", agentName, service)
|
||||||
|
|
||||||
|
// If no ClickHouse, just consume the stream without storing
|
||||||
|
if !c.logRepo.IsConnected() {
|
||||||
|
log.Printf("Warning: ClickHouse not connected yet, consuming logs without storing for agent %s", agentName)
|
||||||
|
for {
|
||||||
|
_, err := stream.Recv()
|
||||||
|
if err == io.EOF {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to receive: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channels for communication with recv goroutine
|
||||||
|
recvCh := make(chan *proto.CollectorRequest, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
// Goroutine that blocks on Recv
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
req, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recvCh <- req
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Buffer for batch inserts
|
||||||
|
var batch []storage.LogEntry
|
||||||
|
ticker := time.NewTicker(c.flushInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
flush := func() error {
|
||||||
|
if len(batch) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := c.logRepo.InsertBatch(stream.Context(), batch); err != nil {
|
||||||
|
log.Printf("Failed to insert batch for agent %s, service %s: %v", agentName, service, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("Flushed %d logs for agent %s, service %s", len(batch), agentName, service)
|
||||||
|
batch = batch[:0]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stream.Context().Done():
|
||||||
|
// Context cancelled, flush remaining
|
||||||
|
_ = flush()
|
||||||
|
return stream.Context().Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case req := <-recvCh:
|
||||||
|
batch = append(batch, storage.LogEntry{
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
Level: "info",
|
||||||
|
Service: service,
|
||||||
|
Agent: agentName,
|
||||||
|
Message: req.Message,
|
||||||
|
})
|
||||||
|
|
||||||
|
if len(batch) >= c.batchSize {
|
||||||
|
if err := flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case err := <-errCh:
|
||||||
|
if err == io.EOF {
|
||||||
|
// Client closed stream
|
||||||
|
return flush()
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to receive: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) GetAgent(name string) (*Agent, bool) {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
a, ok := c.agents[name]
|
||||||
|
return a, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Collector) Agents() []*Agent {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
result := make([]*Agent, 0, len(c.agents))
|
||||||
|
for _, a := range c.agents {
|
||||||
|
result = append(result, a)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package commander
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/proto/proto"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Commander struct {
|
||||||
|
proto.UnimplementedCommanderServer
|
||||||
|
agents map[string]Agent
|
||||||
|
mu sync.RWMutex
|
||||||
|
jobber Jobber
|
||||||
|
}
|
||||||
|
|
||||||
|
type Jobber interface {
|
||||||
|
InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error)
|
||||||
|
UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(jobber Jobber) *Commander {
|
||||||
|
return &Commander{
|
||||||
|
agents: make(map[string]Agent),
|
||||||
|
jobber: jobber,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Agent struct {
|
||||||
|
bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command]
|
||||||
|
in chan *proto.Command
|
||||||
|
jobs map[int64]Job
|
||||||
|
jobber Jobber
|
||||||
|
ctx context.Context
|
||||||
|
aid string
|
||||||
|
|
||||||
|
Token string // agent id
|
||||||
|
Label string
|
||||||
|
Services []string
|
||||||
|
}
|
||||||
|
type JobOut struct {
|
||||||
|
fc models.Job
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
out chan JobOut
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Commander) GetAgent(aid string) (agent Agent, ok bool) {
|
||||||
|
self.mu.RLock()
|
||||||
|
defer self.mu.RUnlock()
|
||||||
|
agent, ok = self.agents[aid]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Commander) Agents() []Agent {
|
||||||
|
self.mu.RLock()
|
||||||
|
defer self.mu.RUnlock()
|
||||||
|
result := make([]Agent, 0, len(self.agents))
|
||||||
|
for _, a := range self.agents {
|
||||||
|
result = append(result, a)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Commander) removeAgent(aid string) {
|
||||||
|
self.mu.Lock()
|
||||||
|
defer self.mu.Unlock()
|
||||||
|
delete(self.agents, aid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Agent) AddJob(job models.JobForInsert) (int64, error) {
|
||||||
|
log.Printf("[DEBUG] AddJob: agent=%s, command=%v", self.aid, job.Command)
|
||||||
|
jid, err := self.jobber.InitJob(self.ctx, self.aid, job)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[DEBUG] AddJob: InitJob failed: %v", err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
log.Printf("[DEBUG] AddJob: InitJob returned jid=%d, sending to self.in channel", jid)
|
||||||
|
self.in <- &proto.Command{
|
||||||
|
Id: jid,
|
||||||
|
Command: job.Command,
|
||||||
|
Stdin: job.Stdin,
|
||||||
|
}
|
||||||
|
log.Printf("[DEBUG] AddJob: sent to self.in channel successfully")
|
||||||
|
return jid, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Agent) WaitJob(jid int64) (*models.Job, error) {
|
||||||
|
log.Printf("[DEBUG] WaitJob: agent=%s, jid=%d, waiting on self.jobs[%d].out", self.aid, jid, jid)
|
||||||
|
result := <-self.jobs[jid].out
|
||||||
|
log.Printf("[DEBUG] WaitJob: agent=%s, jid=%d, received result", self.aid, jid)
|
||||||
|
return &result.fc, result.err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Commander) Stream(bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command]) error {
|
||||||
|
md, ok := metadata.FromIncomingContext(bidi.Context())
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no metadata in context")
|
||||||
|
}
|
||||||
|
aidVals := md["agentid"]
|
||||||
|
if len(aidVals) == 0 {
|
||||||
|
return fmt.Errorf("agentid metadata missing")
|
||||||
|
}
|
||||||
|
aid := aidVals[0]
|
||||||
|
|
||||||
|
var label string
|
||||||
|
labelVals := md["label"]
|
||||||
|
if len(labelVals) > 0 {
|
||||||
|
label = labelVals[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
agent := newAgent(bidi, self.jobber, aid, label)
|
||||||
|
self.mu.Lock()
|
||||||
|
self.agents[aid] = agent
|
||||||
|
self.mu.Unlock()
|
||||||
|
|
||||||
|
defer self.removeAgent(aid)
|
||||||
|
return agent.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Agent) run() error {
|
||||||
|
wg := new(errgroup.Group)
|
||||||
|
wg.Go(self.recv)
|
||||||
|
wg.Go(self.send)
|
||||||
|
return wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Agent) recv() error {
|
||||||
|
for {
|
||||||
|
job, err := func() (job models.Job, err error) {
|
||||||
|
msg, err := self.bidi.Recv()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[DEBUG] recv: agent=%s, received finished job id=%d", self.aid, msg.Id)
|
||||||
|
return self.jobber.UpdateJobInDB(self.ctx, msg.Id, models.JobForUpdate{
|
||||||
|
Stdout: msg.Stdout,
|
||||||
|
Stderr: msg.Stderr,
|
||||||
|
Status: msg.Status,
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
if err == io.EOF {
|
||||||
|
log.Printf("[DEBUG] recv: agent=%s, EOF received", self.aid)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[DEBUG] recv: agent=%s, error: %v", self.aid, err)
|
||||||
|
}
|
||||||
|
out := self.jobs[job.ID].out
|
||||||
|
out <- JobOut{
|
||||||
|
fc: job,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
close(out)
|
||||||
|
log.Printf("[DEBUG] recv: agent=%s, sent result for job id=%d", self.aid, job.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *Agent) send() error {
|
||||||
|
for job := range self.in {
|
||||||
|
log.Printf("[DEBUG] send: agent=%s, job id=%d, command=%v", self.aid, job.Id, job.Command)
|
||||||
|
self.jobs[job.Id] = newJob()
|
||||||
|
if err := self.bidi.Send(job); err != nil {
|
||||||
|
log.Printf("[DEBUG] send: agent=%s, failed to send job id=%d: %v", self.aid, job.Id, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[DEBUG] send: agent=%s, sent job id=%d to agent", self.aid, job.Id)
|
||||||
|
}
|
||||||
|
log.Printf("[DEBUG] send: agent=%s, self.in channel closed", self.aid)
|
||||||
|
return io.EOF
|
||||||
|
// self.jobs[]
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAgent(bidi grpc.BidiStreamingServer[proto.FinishedCommand, proto.Command], jobber Jobber, aid string, label string) Agent {
|
||||||
|
return Agent{
|
||||||
|
bidi: bidi,
|
||||||
|
in: make(chan *proto.Command),
|
||||||
|
jobs: make(map[int64]Job),
|
||||||
|
jobber: jobber,
|
||||||
|
ctx: bidi.Context(),
|
||||||
|
aid: aid,
|
||||||
|
Label: label,
|
||||||
|
Token: aid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJob() Job {
|
||||||
|
return Job{make(chan JobOut, 1)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/ansible"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentDeployGroup struct {
|
||||||
|
*Handlers
|
||||||
|
executor *ansible.Executor
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentDeployGroup(h *Handlers) *AgentDeployGroup {
|
||||||
|
workDir := os.Getenv("ANSIBLE_WORK_DIR")
|
||||||
|
if workDir == "" {
|
||||||
|
workDir = "/tmp/hellreign/ansible"
|
||||||
|
}
|
||||||
|
|
||||||
|
grpcPort := os.Getenv("GRPC_PORT")
|
||||||
|
if grpcPort == "" {
|
||||||
|
grpcPort = "9001"
|
||||||
|
}
|
||||||
|
|
||||||
|
backendURL := os.Getenv("BACKEND_URL")
|
||||||
|
if backendURL == "" {
|
||||||
|
backendURL = "http://localhost:8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
exec := ansible.NewExecutor(ansible.ExecutorConfig{
|
||||||
|
WorkDir: workDir,
|
||||||
|
GRPCServerHost: "0.0.0.0", // TODO: make configurable
|
||||||
|
GRPCServerPort: grpcPort,
|
||||||
|
BackendURL: backendURL,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Write playbooks on init
|
||||||
|
if err := exec.WriteAllPlaybooks(); err != nil {
|
||||||
|
// Log but don't fail - playbooks can be written later
|
||||||
|
_ = err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &AgentDeployGroup{
|
||||||
|
Handlers: h,
|
||||||
|
executor: exec,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployAgents deploys agents to multiple servers
|
||||||
|
// @Summary Deploy agents to multiple servers via Ansible
|
||||||
|
// @Description Deploy HellreigN agents to multiple servers using Ansible playbooks. Supports Docker and Binary deployment types.
|
||||||
|
// @Tags agents
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body repository.DeployAgentsRequest true "Deployment configuration for servers"
|
||||||
|
// @Success 200 {object} repository.DeployResponse "Deployment results with tokens for each server"
|
||||||
|
// @Failure 400 {object} map[string]string "Invalid request"
|
||||||
|
// @Failure 500 {object} map[string]string "Internal server error"
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /agents/deploy [post]
|
||||||
|
func (adg *AgentDeployGroup) DeployAgents(c *gin.Context) {
|
||||||
|
var req repository.DeployAgentsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create work directory
|
||||||
|
workDir := adg.executor.WorkDir()
|
||||||
|
if err := os.MkdirAll(workDir, 0755); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create work directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate registration tokens for each server
|
||||||
|
results := make([]repository.DeployResult, 0, len(req.Servers))
|
||||||
|
timestamp := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for i, server := range req.Servers {
|
||||||
|
// Create registration token
|
||||||
|
token, err := adg.Repo.CreateRegistrationToken(server.AgentLabel)
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, repository.DeployResult{
|
||||||
|
IP: server.IP,
|
||||||
|
AgentLabel: server.AgentLabel,
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to create token: %v", err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default port
|
||||||
|
port := server.Port
|
||||||
|
if port == 0 {
|
||||||
|
port = 22
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate inventory for this single server
|
||||||
|
inventoryHosts := []ansible.InventoryHost{
|
||||||
|
{
|
||||||
|
Name: server.AgentLabel,
|
||||||
|
IP: server.IP,
|
||||||
|
Port: port,
|
||||||
|
User: server.User,
|
||||||
|
AuthMethod: string(server.AuthMethod),
|
||||||
|
SSHKey: server.SSHKey,
|
||||||
|
Password: server.Password,
|
||||||
|
DeployType: string(server.DeployType),
|
||||||
|
Token: token,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
inventoryPath := filepath.Join(workDir, fmt.Sprintf("inventory_%d_%d", timestamp, i))
|
||||||
|
if err := ansible.GenerateInventory(inventoryHosts, inventoryPath); err != nil {
|
||||||
|
results = append(results, repository.DeployResult{
|
||||||
|
IP: server.IP,
|
||||||
|
AgentLabel: server.AgentLabel,
|
||||||
|
Token: token,
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("failed to generate inventory: %v", err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run Ansible playbook for this server
|
||||||
|
deployResults, err := adg.executor.Deploy(ctx, inventoryPath, string(server.DeployType))
|
||||||
|
|
||||||
|
// Clean up inventory file
|
||||||
|
os.Remove(inventoryPath)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
results = append(results, repository.DeployResult{
|
||||||
|
IP: server.IP,
|
||||||
|
AgentLabel: server.AgentLabel,
|
||||||
|
Token: token,
|
||||||
|
Success: false,
|
||||||
|
Error: fmt.Sprintf("deployment failed: %v", err),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
success := true
|
||||||
|
errMsg := ""
|
||||||
|
if len(deployResults) > 0 && !deployResults[0].Success {
|
||||||
|
success = false
|
||||||
|
errMsg = deployResults[0].Stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, repository.DeployResult{
|
||||||
|
IP: server.IP,
|
||||||
|
AgentLabel: server.AgentLabel,
|
||||||
|
Token: token,
|
||||||
|
Success: success,
|
||||||
|
Error: errMsg,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, repository.DeployResponse{
|
||||||
|
Message: "Deployment completed",
|
||||||
|
Results: results,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/utils"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentRegistrationGroup struct {
|
||||||
|
*Handlers
|
||||||
|
certBundle *utils.CertBundle
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentRegistrationGroup(h *Handlers) *AgentRegistrationGroup {
|
||||||
|
certDir := getCertDir()
|
||||||
|
bundle, err := utils.LoadCertBundle(certDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[agent-reg] WARNING: cert bundle load failed: %v", err)
|
||||||
|
}
|
||||||
|
return &AgentRegistrationGroup{
|
||||||
|
Handlers: h,
|
||||||
|
certBundle: bundle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRegistrationToken — админ создаёт токен для агента
|
||||||
|
// @Summary Create registration token
|
||||||
|
// @Tags agents
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body repository.RegistrationRequest true "Label"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /agents/register-token [post]
|
||||||
|
func (arg *AgentRegistrationGroup) CreateRegistrationToken(c *gin.Context) {
|
||||||
|
var req repository.RegistrationRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := arg.Repo.CreateRegistrationToken(req.Label)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"token": token})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register — агент шлёт CSR + token, получает сертификаты
|
||||||
|
// @Summary Register agent
|
||||||
|
// @Tags agents
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param request body RegisterRequest true "CSR + token"
|
||||||
|
// @Success 200 {object} RegisterResponse
|
||||||
|
// @Router /agents/register [post]
|
||||||
|
func (arg *AgentRegistrationGroup) Register(c *gin.Context) {
|
||||||
|
var req RegisterRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if arg.certBundle == nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "certificate bundle not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
regToken, err := arg.Repo.GetRegistrationToken(req.Token)
|
||||||
|
if err != nil {
|
||||||
|
if err == repository.ErrNotFound {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid registration token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to verify token"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if regToken.Used {
|
||||||
|
c.JSON(http.StatusGone, gin.H{"error": "registration token already used"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientCertPEM, err := arg.certBundle.SignCSR([]byte(req.CSR), regToken.Label)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to sign CSR: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := arg.Repo.MarkRegistrationTokenUsed(req.Token); err != nil {
|
||||||
|
log.Printf("[agent-reg] WARNING: failed to mark token used: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, RegisterResponse{
|
||||||
|
CACert: string(arg.certBundle.GetCACertPEM()),
|
||||||
|
ClientCert: string(clientCertPEM),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterRequest struct {
|
||||||
|
CSR string `json:"csr" binding:"required"`
|
||||||
|
Token string `json:"token" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RegisterResponse struct {
|
||||||
|
CACert string `json:"ca_cert"`
|
||||||
|
ClientCert string `json:"client_cert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCertDir() string {
|
||||||
|
if d := os.Getenv("SSL_CERT_DIR"); d != "" {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return "/var/lib/hellreign/ssl"
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/collector"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentsGroup struct {
|
||||||
|
*Handlers
|
||||||
|
collector *collector.Collector
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentsGroup(h *Handlers, coll *collector.Collector) AgentsGroup {
|
||||||
|
return AgentsGroup{Handlers: h, collector: coll}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentInfo struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Services []string `json:"services"`
|
||||||
|
ConnectedAt string `json:"connected_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Get connected agents
|
||||||
|
// @Description Returns a list of all agents currently connected via Collector (log streaming)
|
||||||
|
// @Tags agents
|
||||||
|
// @Security Bearer
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} AgentInfo
|
||||||
|
// @Router /agents [get]
|
||||||
|
func (ag *AgentsGroup) List(c *gin.Context) {
|
||||||
|
agents := make([]AgentInfo, 0)
|
||||||
|
|
||||||
|
for _, agent := range ag.collector.Agents() {
|
||||||
|
agents = append(agents, AgentInfo{
|
||||||
|
Token: agent.ID,
|
||||||
|
Label: agent.Label,
|
||||||
|
Services: agent.Services,
|
||||||
|
ConnectedAt: agent.ConnectedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, agents)
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthGroup handles authentication routes.
|
||||||
|
type AuthGroup struct {
|
||||||
|
*Handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates a user by login and password, returns a token.
|
||||||
|
// @Summary Login
|
||||||
|
// @Description Authenticate with login and password, returns a token and permissions
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Param request body repository.LoginRequest true "Login credentials"
|
||||||
|
// @Success 200 {object} repository.LoginResponse
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Failure 403 {object} map[string]string
|
||||||
|
// @Router /auth/login [post]
|
||||||
|
func (ag *AuthGroup) Login(c *gin.Context) {
|
||||||
|
var req repository.LoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := ag.Repo.Login(req.Login, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errors.Is(err, repository.ErrAccountInactive) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "account is not activated by admin"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to authenticate"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateToken creates a new user.
|
||||||
|
// @Summary Create user
|
||||||
|
// @Description Creates a new user with permissions
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Param request body repository.TokenCreate true "User data"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/token [post]
|
||||||
|
func (ag *AuthGroup) CreateToken(c *gin.Context) {
|
||||||
|
var tc repository.TokenCreate
|
||||||
|
if err := c.ShouldBindJSON(&tc); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := ag.Repo.CreateToken(tc); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user created"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateToken validates the current Bearer token and returns user info.
|
||||||
|
// @Summary Validate token
|
||||||
|
// @Description Check if the provided Bearer token is valid and return its permissions
|
||||||
|
// @Tags auth
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} repository.Tokens
|
||||||
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Router /auth/validate [get]
|
||||||
|
func (ag *AuthGroup) ValidateToken(c *gin.Context) {
|
||||||
|
tokenVal, exists := c.Get(string(tokenContextKey))
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, ok := tokenVal.(*repository.Tokens)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token context"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokens returns all users.
|
||||||
|
// @Summary List users
|
||||||
|
// @Description Returns list of all users with their permissions
|
||||||
|
// @Tags auth
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} repository.Tokens
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/tokens [get]
|
||||||
|
func (ag *AuthGroup) ListTokens(c *gin.Context) {
|
||||||
|
tokens, err := ag.Repo.ListTokens()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list users"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteToken deletes a user by login from URL path.
|
||||||
|
// @Summary Delete user
|
||||||
|
// @Description Deletes a user by their login
|
||||||
|
// @Tags auth
|
||||||
|
// @Param login path string true "Login of the user to delete"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/tokens/:login [delete]
|
||||||
|
func (ag *AuthGroup) DeleteToken(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.DeleteTokenByLogin(login); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMyToken deletes the current user's account.
|
||||||
|
// @Summary Delete my account
|
||||||
|
// @Description Deletes the current authenticated user
|
||||||
|
// @Tags auth
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 401 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/token [delete]
|
||||||
|
func (ag *AuthGroup) DeleteMyToken(c *gin.Context) {
|
||||||
|
tokenVal, exists := c.Get(string(tokenContextKey))
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, ok := tokenVal.(*repository.Tokens)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token context"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.DeleteToken(token.Token); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete account"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "account deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateUser activates a user by login.
|
||||||
|
// @Summary Activate user
|
||||||
|
// @Description Activates a user account by login (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Param login path string true "Login of the user to activate"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/users/:login/activate [post]
|
||||||
|
func (ag *AuthGroup) ActivateUser(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.ActivateUserByLogin(login); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to activate user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user activated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateUser deactivates a user by login.
|
||||||
|
// @Summary Deactivate user
|
||||||
|
// @Description Deactivates a user account by login (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Param login path string true "Login of the user to deactivate"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/users/:login/deactivate [post]
|
||||||
|
func (ag *AuthGroup) DeactivateUser(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.DeactivateUserByLogin(login); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to deactivate user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user deactivated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInactiveUsers returns all users that are not activated.
|
||||||
|
// @Summary List inactive users
|
||||||
|
// @Description Returns list of all users waiting for activation
|
||||||
|
// @Tags auth
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} repository.Tokens
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/users/inactive [get]
|
||||||
|
func (ag *AuthGroup) ListInactiveUsers(c *gin.Context) {
|
||||||
|
tokens, err := ag.Repo.ListInactiveTokens()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list inactive users"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser returns a user by login.
|
||||||
|
// @Summary Get user by login
|
||||||
|
// @Description Returns a user by their login (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Produce json
|
||||||
|
// @Param login path string true "Login of the user"
|
||||||
|
// @Success 200 {object} repository.Tokens
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/users/:login [get]
|
||||||
|
func (ag *AuthGroup) GetUser(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := ag.Repo.GetTokenByLogin(login)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates user's name and last name.
|
||||||
|
// @Summary Update user
|
||||||
|
// @Description Updates a user's name and last name (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Param login path string true "Login of the user"
|
||||||
|
// @Param request body repository.TokenUpdate true "User data to update"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/users/:login [put]
|
||||||
|
func (ag *AuthGroup) UpdateUser(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var update repository.TokenUpdate
|
||||||
|
if err := c.ShouldBindJSON(&update); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.UpdateToken(login, update); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update user"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "user updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUserPermissions updates user's permissions and activation status.
|
||||||
|
// @Summary Update user permissions
|
||||||
|
// @Description Updates a user's permissions and activation status (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Param login path string true "Login of the user"
|
||||||
|
// @Param request body repository.TokenUpdatePermissions true "Permissions to update"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/users/:login/permissions [put]
|
||||||
|
func (ag *AuthGroup) UpdateUserPermissions(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var update repository.TokenUpdatePermissions
|
||||||
|
if err := c.ShouldBindJSON(&update); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.UpdatePermissions(login, update); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update permissions"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "permissions updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetUserPassword resets a user's password.
|
||||||
|
// @Summary Reset user password
|
||||||
|
// @Description Resets a user's password to a new value (admin only)
|
||||||
|
// @Tags auth
|
||||||
|
// @Accept json
|
||||||
|
// @Param login path string true "Login of the user"
|
||||||
|
// @Param request body repository.TokenPasswordReset true "New password"
|
||||||
|
// @Success 200 {object} map[string]string
|
||||||
|
// @Failure 400 {object} map[string]string
|
||||||
|
// @Failure 404 {object} map[string]string
|
||||||
|
// @Failure 500 {object} map[string]string
|
||||||
|
// @Router /auth/users/:login/password [put]
|
||||||
|
func (ag *AuthGroup) ResetUserPassword(c *gin.Context) {
|
||||||
|
login := c.Param("login")
|
||||||
|
if login == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "login required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req repository.TokenPasswordReset
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ag.Repo.UpdatePassword(login, req.NewPassword); err != nil {
|
||||||
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reset password"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "password reset"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getTokenFromHeader extracts the Bearer token from the Authorization header.
|
||||||
|
func getTokenFromHeader(c *gin.Context) string {
|
||||||
|
auth := c.GetHeader("Authorization")
|
||||||
|
if auth == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parts := strings.SplitN(auth, " ", 2)
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Handlers struct {
|
||||||
|
DB *sql.DB
|
||||||
|
Repo *repository.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *sql.DB) *Handlers {
|
||||||
|
return &Handlers{
|
||||||
|
DB: db,
|
||||||
|
Repo: repository.New(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobsHandlers struct {
|
||||||
|
cmder *commander.Commander
|
||||||
|
svc *service.ScriptService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJobsHandlers(cmder *commander.Commander, svc *service.ScriptService) JobsHandlers {
|
||||||
|
return JobsHandlers{cmder: cmder, svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddJobIn struct {
|
||||||
|
Command string `json:"command" binding:"required"`
|
||||||
|
InterpreterID int64 `json:"interpreter_id"`
|
||||||
|
Stdin *string `json:"stdin"`
|
||||||
|
AgentID string `json:"agent_id" binding:"required"`
|
||||||
|
}
|
||||||
|
type AddJobOut struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Command []string `json:"command"`
|
||||||
|
Stdin *string `json:"stdin"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Status int32 `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddJob creates and executes a job on a target agent.
|
||||||
|
// @Summary Create and run a job on an agent
|
||||||
|
// @Description Sends a command to the specified agent, waits for execution, and returns the result
|
||||||
|
// @Tags jobs
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body AddJobIn true "Job request"
|
||||||
|
// @Success 201 {object} AddJobOut
|
||||||
|
// @Router /jobs [post]
|
||||||
|
func (self *JobsHandlers) AddJob(c *gin.Context) {
|
||||||
|
log.Printf("[DEBUG] AddJob handler: request received")
|
||||||
|
err := func() error {
|
||||||
|
var in AddJobIn
|
||||||
|
if err := c.Bind(&in); err != nil {
|
||||||
|
log.Printf("[DEBUG] AddJob handler: bind failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[DEBUG] AddJob handler: agent_id=%s, command=%s, interpreter_id=%d", in.AgentID, in.Command, in.InterpreterID)
|
||||||
|
agent, ok := self.cmder.GetAgent(in.AgentID)
|
||||||
|
if !ok {
|
||||||
|
log.Printf("[DEBUG] AddJob handler: agent %s not found", in.AgentID)
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return fmt.Errorf("agent not found")
|
||||||
|
}
|
||||||
|
log.Printf("[DEBUG] AddJob handler: agent found, resolving command")
|
||||||
|
|
||||||
|
var command []string
|
||||||
|
if in.InterpreterID == 0 {
|
||||||
|
command = []string{"sh", "-c", in.Command}
|
||||||
|
} else {
|
||||||
|
var err error
|
||||||
|
command, err = self.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.Command)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[DEBUG] AddJob handler: ResolveCommand failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[DEBUG] AddJob handler: calling agent.AddJob with command=%v", command)
|
||||||
|
jid, err := agent.AddJob(models.JobForInsert{
|
||||||
|
Command: command,
|
||||||
|
Stdin: in.Stdin,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[DEBUG] AddJob handler: agent.AddJob failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[DEBUG] AddJob handler: agent.AddJob returned jid=%d, calling WaitJob", jid)
|
||||||
|
job, err := agent.WaitJob(jid)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[DEBUG] AddJob handler: agent.WaitJob failed: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Printf("[DEBUG] AddJob handler: agent.WaitJob returned job id=%d, status=%d", job.ID, job.Status)
|
||||||
|
c.JSON(http.StatusCreated, AddJobOut{
|
||||||
|
ID: job.ID,
|
||||||
|
Command: job.Command,
|
||||||
|
Stdin: job.Stdin,
|
||||||
|
Stdout: job.Stdout,
|
||||||
|
Stderr: job.Stderr,
|
||||||
|
Status: job.Status,
|
||||||
|
})
|
||||||
|
log.Printf("[DEBUG] AddJob handler: response sent")
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogHandlers struct {
|
||||||
|
LogRepo *repository.LogRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogHandlers(logRepo *repository.LogRepository) *LogHandlers {
|
||||||
|
return &LogHandlers{LogRepo: logRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
type InsertLogRequest struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Level string `json:"level" binding:"required"`
|
||||||
|
Service string `json:"service" binding:"required"`
|
||||||
|
Agent string `json:"agent" binding:"required"`
|
||||||
|
Message string `json:"message" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Insert log entry
|
||||||
|
// @Description Inserts a single log entry into ClickHouse
|
||||||
|
// @Tags logs
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body InsertLogRequest true "Log entry"
|
||||||
|
// @Success 201 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /logs [post]
|
||||||
|
func (lh *LogHandlers) Insert(c *gin.Context) {
|
||||||
|
var req InsertLogRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Timestamp.IsZero() {
|
||||||
|
req.Timestamp = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
log := storage.LogEntry{
|
||||||
|
Timestamp: req.Timestamp,
|
||||||
|
Level: req.Level,
|
||||||
|
Service: req.Service,
|
||||||
|
Agent: req.Agent,
|
||||||
|
Message: req.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lh.LogRepo.Insert(c.Request.Context(), log); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert log"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type InsertLogsRequest struct {
|
||||||
|
Logs []InsertLogRequest `json:"logs" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Insert log entries (batch)
|
||||||
|
// @Description Inserts multiple log entries into ClickHouse
|
||||||
|
// @Tags logs
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body InsertLogsRequest true "Log entries"
|
||||||
|
// @Success 201 {object} map[string]string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /logs/batch [post]
|
||||||
|
func (lh *LogHandlers) InsertBatch(c *gin.Context) {
|
||||||
|
var req InsertLogsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := make([]storage.LogEntry, len(req.Logs))
|
||||||
|
for i, l := range req.Logs {
|
||||||
|
if l.Timestamp.IsZero() {
|
||||||
|
l.Timestamp = time.Now()
|
||||||
|
}
|
||||||
|
logs[i] = storage.LogEntry{
|
||||||
|
Timestamp: l.Timestamp,
|
||||||
|
Level: l.Level,
|
||||||
|
Service: l.Service,
|
||||||
|
Agent: l.Agent,
|
||||||
|
Message: l.Message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := lh.LogRepo.InsertBatch(c.Request.Context(), logs); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to insert logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, gin.H{"status": "ok", "count": len(logs)})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchLogsRequest struct {
|
||||||
|
Level string `form:"level"`
|
||||||
|
Service string `form:"service"`
|
||||||
|
Agent string `form:"agent"`
|
||||||
|
DateFrom string `form:"date_from"`
|
||||||
|
DateTo string `form:"date_to"`
|
||||||
|
Limit int `form:"limit"`
|
||||||
|
Offset int `form:"offset"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Search logs
|
||||||
|
// @Description Searches logs with various filters
|
||||||
|
// @Tags logs
|
||||||
|
// @Produce json
|
||||||
|
// @Param level query string false "Log level (INFO, WARNING, ERROR, FATAL)"
|
||||||
|
// @Param service query string false "Service name"
|
||||||
|
// @Param agent query string false "Agent name"
|
||||||
|
// @Param date_from query string false "Date from (RFC3339)"
|
||||||
|
// @Param date_to query string false "Date to (RFC3339)"
|
||||||
|
// @Param limit query int false "Limit results" default(100)
|
||||||
|
// @Param offset query int false "Offset results" default(0)
|
||||||
|
// @Success 200 {array} storage.LogEntry
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /logs [get]
|
||||||
|
func (lh *LogHandlers) Search(c *gin.Context) {
|
||||||
|
var req SearchLogsRequest
|
||||||
|
if err := c.ShouldBindQuery(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filter := repository.LogFilter{
|
||||||
|
Level: req.Level,
|
||||||
|
Service: req.Service,
|
||||||
|
Agent: req.Agent,
|
||||||
|
Limit: req.Limit,
|
||||||
|
Offset: req.Offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateFrom != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, req.DateFrom); err == nil {
|
||||||
|
filter.DateFrom = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DateTo != "" {
|
||||||
|
if t, err := time.Parse(time.RFC3339, req.DateTo); err == nil {
|
||||||
|
filter.DateTo = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Limit <= 0 {
|
||||||
|
filter.Limit = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := lh.LogRepo.Search(c.Request.Context(), filter)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to search logs"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, logs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Get distinct services
|
||||||
|
// @Description Returns list of all unique service names in logs
|
||||||
|
// @Tags logs
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /logs/services [get]
|
||||||
|
func (lh *LogHandlers) GetServices(c *gin.Context) {
|
||||||
|
services, err := lh.LogRepo.GetDistinctServices(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get services"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if services == nil {
|
||||||
|
services = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, services)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Get distinct agents
|
||||||
|
// @Description Returns list of all unique agent names in logs
|
||||||
|
// @Tags logs
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /logs/agents [get]
|
||||||
|
func (lh *LogHandlers) GetAgents(c *gin.Context) {
|
||||||
|
agents, err := lh.LogRepo.GetDistinctAgents(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agents"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if agents == nil {
|
||||||
|
agents = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, agents)
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Get distinct log levels
|
||||||
|
// @Description Returns list of all unique log levels in logs
|
||||||
|
// @Tags logs
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} string
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /logs/levels [get]
|
||||||
|
func (lh *LogHandlers) GetLevels(c *gin.Context) {
|
||||||
|
levels, err := lh.LogRepo.GetDistinctLevels(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get levels"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if levels == nil {
|
||||||
|
levels = []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, levels)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure context is used
|
||||||
|
var _ = context.Background
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetMockLogs returns 100 mock log entries for frontend development
|
||||||
|
// @Summary Get mock logs
|
||||||
|
// @Description Returns 100 mock log entries for frontend development (no ClickHouse required)
|
||||||
|
// @Tags logs
|
||||||
|
// @Produce json
|
||||||
|
// @Param level query string false "Filter by level"
|
||||||
|
// @Param service query string false "Filter by service"
|
||||||
|
// @Param agent query string false "Filter by agent"
|
||||||
|
// @Param limit query int false "Limit results" default(100)
|
||||||
|
// @Param offset query int false "Offset results" default(0)
|
||||||
|
// @Success 200 {array} storage.LogEntry
|
||||||
|
// @Security Bearer
|
||||||
|
// @Router /logs/mock [get]
|
||||||
|
func (lh *LogHandlers) GetMockLogs(c *gin.Context) {
|
||||||
|
levelFilter := c.Query("level")
|
||||||
|
serviceFilter := c.Query("service")
|
||||||
|
agentFilter := c.Query("agent")
|
||||||
|
|
||||||
|
limit := 100
|
||||||
|
offset := 0
|
||||||
|
|
||||||
|
if l := c.Query("limit"); l != "" {
|
||||||
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if o := c.Query("offset"); o != "" {
|
||||||
|
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||||
|
offset = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logs := generateMockLogs(100)
|
||||||
|
|
||||||
|
// Apply filters
|
||||||
|
var filtered []storage.LogEntry
|
||||||
|
for _, log := range logs {
|
||||||
|
if levelFilter != "" && log.Level != levelFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if serviceFilter != "" && log.Service != serviceFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if agentFilter != "" && log.Agent != agentFilter {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(filtered) {
|
||||||
|
end = len(filtered)
|
||||||
|
}
|
||||||
|
if offset > len(filtered) {
|
||||||
|
filtered = []storage.LogEntry{}
|
||||||
|
} else {
|
||||||
|
filtered = filtered[offset:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, filtered)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateMockLogs(count int) []storage.LogEntry {
|
||||||
|
services := []string{
|
||||||
|
"auth-service",
|
||||||
|
"user-service",
|
||||||
|
"agent-service",
|
||||||
|
"gateway",
|
||||||
|
"scheduler",
|
||||||
|
"notification-service",
|
||||||
|
"metrics-collector",
|
||||||
|
"deployment-service",
|
||||||
|
}
|
||||||
|
|
||||||
|
agents := []string{
|
||||||
|
"agent-prod-01",
|
||||||
|
"agent-prod-02",
|
||||||
|
"agent-staging-01",
|
||||||
|
"agent-dev-01",
|
||||||
|
"agent-dev-02",
|
||||||
|
"agent-monitoring-01",
|
||||||
|
"agent-backup-01",
|
||||||
|
"agent-ci-runner-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
levels := []string{"INFO", "WARNING", "ERROR", "FATAL", "DEBUG"}
|
||||||
|
levelWeights := []int{50, 20, 15, 5, 10} // weighted distribution
|
||||||
|
|
||||||
|
messages := map[string][]string{
|
||||||
|
"INFO": {
|
||||||
|
"Service started successfully",
|
||||||
|
"Health check passed",
|
||||||
|
"Configuration loaded",
|
||||||
|
"Connection established to database",
|
||||||
|
"Cache refreshed successfully",
|
||||||
|
"Request processed in 45ms",
|
||||||
|
"User login successful",
|
||||||
|
"Agent registered successfully",
|
||||||
|
"Deployment completed for 3 servers",
|
||||||
|
"Metrics exported to storage",
|
||||||
|
"Backup completed successfully",
|
||||||
|
"SSL certificate valid for 89 days",
|
||||||
|
"Task scheduled: cleanup-temp-files",
|
||||||
|
"Webhook delivered successfully",
|
||||||
|
"Session created for user admin",
|
||||||
|
},
|
||||||
|
"WARNING": {
|
||||||
|
"High memory usage detected: 85%",
|
||||||
|
"Slow query detected: 2.3s",
|
||||||
|
"Rate limit approaching for client 192.168.1.50",
|
||||||
|
"Certificate expires in 7 days",
|
||||||
|
"Retry attempt 2/3 for request",
|
||||||
|
"Disk usage above threshold: 78%",
|
||||||
|
"Connection pool nearly exhausted: 45/50",
|
||||||
|
"Deprecated API endpoint called: /api/v1/legacy",
|
||||||
|
"Response time exceeded SLA: 1.2s > 1s",
|
||||||
|
"Agent heartbeat delayed by 5s",
|
||||||
|
},
|
||||||
|
"ERROR": {
|
||||||
|
"Failed to connect to database: timeout after 30s",
|
||||||
|
"Authentication failed for user test_user",
|
||||||
|
"Agent deployment failed: SSH connection refused",
|
||||||
|
"Failed to send notification: SMTP server unavailable",
|
||||||
|
"Request failed with status 500",
|
||||||
|
"File not found: /etc/hellreign/config.yml",
|
||||||
|
"Invalid token provided",
|
||||||
|
"Permission denied for user viewer",
|
||||||
|
"Failed to parse configuration: invalid YAML",
|
||||||
|
"Agent unreachable: connection timeout",
|
||||||
|
},
|
||||||
|
"FATAL": {
|
||||||
|
"Out of memory: cannot allocate 512MB",
|
||||||
|
"Database connection lost, all retries exhausted",
|
||||||
|
"Critical: SSL certificate expired",
|
||||||
|
"Unrecoverable error: data corruption detected",
|
||||||
|
"Service crashed: segmentation fault",
|
||||||
|
},
|
||||||
|
"DEBUG": {
|
||||||
|
"Processing request payload: 2.3KB",
|
||||||
|
"Cache hit ratio: 78%",
|
||||||
|
"Executing query: SELECT * FROM logs WHERE...",
|
||||||
|
"HTTP request headers: {Content-Type: application/json}",
|
||||||
|
"Agent status check: 8 agents online",
|
||||||
|
"Memory allocation: 256MB used of 1024MB",
|
||||||
|
"Thread pool size: 12 active, 4 idle",
|
||||||
|
"GC pause: 15ms",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
r := rand.New(rand.NewSource(42)) // fixed seed for reproducibility
|
||||||
|
|
||||||
|
var logs []storage.LogEntry
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for i := 0; i < count; i++ {
|
||||||
|
level := weightedRandom(r, levels, levelWeights)
|
||||||
|
service := services[r.Intn(len(services))]
|
||||||
|
agent := agents[r.Intn(len(agents))]
|
||||||
|
msgs := messages[level]
|
||||||
|
message := msgs[r.Intn(len(msgs))]
|
||||||
|
|
||||||
|
// Spread logs over the last 24 hours
|
||||||
|
timestamp := now.Add(-time.Duration(count-i) * time.Minute * 15)
|
||||||
|
|
||||||
|
logs = append(logs, storage.LogEntry{
|
||||||
|
Timestamp: timestamp,
|
||||||
|
Level: level,
|
||||||
|
Service: service,
|
||||||
|
Agent: agent,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs
|
||||||
|
}
|
||||||
|
|
||||||
|
func weightedRandom(r *rand.Rand, items []string, weights []int) string {
|
||||||
|
total := 0
|
||||||
|
for _, w := range weights {
|
||||||
|
total += w
|
||||||
|
}
|
||||||
|
n := r.Intn(total)
|
||||||
|
for i, w := range weights {
|
||||||
|
n -= w
|
||||||
|
if n < 0 {
|
||||||
|
return items[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items[len(items)-1]
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TokenContextKey is the context key for storing authenticated token info.
|
||||||
|
type TokenContextKey string
|
||||||
|
|
||||||
|
const tokenContextKey TokenContextKey = "token"
|
||||||
|
|
||||||
|
// AuthMiddleware validates that a Bearer token exists and is valid.
|
||||||
|
// It stores the token info in the context for later use.
|
||||||
|
// Returns 401 if token is missing or invalid.
|
||||||
|
func (ag *AuthGroup) AuthMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
token := getTokenFromHeader(c)
|
||||||
|
if token == "" {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing authorization header"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up user by token value
|
||||||
|
tokens, err := ag.Repo.GetToken(token)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set(string(tokenContextKey), tokens)
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequirePermission is a generic permission checker.
|
||||||
|
func RequirePermission(check func(*repository.Tokens) bool) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
tokenVal, exists := c.Get(string(tokenContextKey))
|
||||||
|
if !exists {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "authentication required"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, ok := tokenVal.(*repository.Tokens)
|
||||||
|
if !ok {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "invalid token context"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !check(token) {
|
||||||
|
c.JSON(http.StatusForbidden, gin.H{"error": "insufficient permissions"})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireView requires permission_view.
|
||||||
|
func RequireView() gin.HandlerFunc {
|
||||||
|
return RequirePermission(func(t *repository.Tokens) bool {
|
||||||
|
return t.PermissionView
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireManageAgent requires permission_manage_agent.
|
||||||
|
func RequireManageAgent() gin.HandlerFunc {
|
||||||
|
return RequirePermission(func(t *repository.Tokens) bool {
|
||||||
|
return t.PermissionManage
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAdmin requires permission_admin.
|
||||||
|
func RequireAdmin() gin.HandlerFunc {
|
||||||
|
return RequirePermission(func(t *repository.Tokens) bool {
|
||||||
|
return t.PermissionAdmin
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/grpcsrv/commander"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/service"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScriptHandlers struct {
|
||||||
|
svc *service.ScriptService
|
||||||
|
cmder *commander.Commander
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptHandlers(svc *service.ScriptService, cmder *commander.Commander) ScriptHandlers {
|
||||||
|
return ScriptHandlers{svc: svc, cmder: cmder}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunScript executes a script on a target agent.
|
||||||
|
// @Summary Run a script on an agent
|
||||||
|
// @Description Resolves interpreter argv[] and sends the full command to the agent
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body RunScriptIn true "Script request"
|
||||||
|
// @Success 201 {object} RunScriptOut
|
||||||
|
// @Router /scripts/run [post]
|
||||||
|
func (self *ScriptHandlers) RunScript(c *gin.Context) {
|
||||||
|
err := func() error {
|
||||||
|
type RunScriptIn struct {
|
||||||
|
AgentID string `json:"agent_id" binding:"required"`
|
||||||
|
InterpreterID int64 `json:"interpreter_id" binding:"required"`
|
||||||
|
ScriptText string `json:"script_text" binding:"required"`
|
||||||
|
Stdin *string `json:"stdin"`
|
||||||
|
}
|
||||||
|
var in RunScriptIn
|
||||||
|
if err := c.Bind(&in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
command, err := self.svc.ResolveCommand(c.Request.Context(), in.InterpreterID, in.ScriptText)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
agent, ok := self.cmder.GetAgent(in.AgentID)
|
||||||
|
if !ok {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return fmt.Errorf("agent not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
jid, err := agent.AddJob(models.JobForInsert{
|
||||||
|
Command: command,
|
||||||
|
Stdin: in.Stdin,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
job, err := agent.WaitJob(jid)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type RunScriptOut struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Command []string `json:"command"`
|
||||||
|
Stdin *string `json:"stdin"`
|
||||||
|
Stdout string `json:"stdout"`
|
||||||
|
Stderr string `json:"stderr"`
|
||||||
|
Status int32 `json:"status"`
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, RunScriptOut{
|
||||||
|
ID: job.ID,
|
||||||
|
Command: job.Command,
|
||||||
|
Stdin: job.Stdin,
|
||||||
|
Stdout: job.Stdout,
|
||||||
|
Stderr: job.Stderr,
|
||||||
|
Status: job.Status,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInterpreters returns all registered script interpreters.
|
||||||
|
// @Summary List interpreters
|
||||||
|
// @Description Returns all script interpreters available in the system
|
||||||
|
// @Tags scripts
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} repository.ScriptInterpreter
|
||||||
|
// @Router /scripts/interpreters [get]
|
||||||
|
func (self *ScriptHandlers) ListInterpreters(c *gin.Context) {
|
||||||
|
interpreters, err := self.svc.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, interpreters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateInterpreter registers a new script interpreter.
|
||||||
|
// @Summary Create interpreter
|
||||||
|
// @Description Registers a new script interpreter with name, label, and argv
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body repository.ScriptInterpreterCreate true "Interpreter definition"
|
||||||
|
// @Success 201 {object} repository.ScriptInterpreter
|
||||||
|
// @Router /scripts/interpreters [post]
|
||||||
|
func (self *ScriptHandlers) CreateInterpreter(c *gin.Context) {
|
||||||
|
var in repository.ScriptInterpreterCreate
|
||||||
|
if err := c.BindJSON(&in); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
si, err := self.svc.Create(c.Request.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, si)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInterpreter returns a single interpreter by ID.
|
||||||
|
// @Summary Get interpreter
|
||||||
|
// @Description Returns a script interpreter by ID
|
||||||
|
// @Tags scripts
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Interpreter ID"
|
||||||
|
// @Success 200 {object} repository.ScriptInterpreter
|
||||||
|
// @Router /scripts/interpreters/:id [get]
|
||||||
|
func (self *ScriptHandlers) GetInterpreter(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
si, err := self.svc.GetByID(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, si)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInterpreter updates an interpreter.
|
||||||
|
// @Summary Update interpreter
|
||||||
|
// @Description Updates fields of a script interpreter
|
||||||
|
// @Tags scripts
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path int true "Interpreter ID"
|
||||||
|
// @Param body body repository.ScriptInterpreterUpdate true "Interpreter fields"
|
||||||
|
// @Success 200 {object} repository.ScriptInterpreter
|
||||||
|
// @Router /scripts/interpreters/:id [put]
|
||||||
|
func (self *ScriptHandlers) UpdateInterpreter(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var in repository.ScriptInterpreterUpdate
|
||||||
|
if err := c.BindJSON(&in); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
si, err := self.svc.Update(c.Request.Context(), id, in)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, si)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteInterpreter removes an interpreter.
|
||||||
|
// @Summary Delete interpreter
|
||||||
|
// @Description Removes a script interpreter by ID
|
||||||
|
// @Tags scripts
|
||||||
|
// @Param id path int true "Interpreter ID"
|
||||||
|
// @Success 204
|
||||||
|
// @Router /scripts/interpreters/:id [delete]
|
||||||
|
func (self *ScriptHandlers) DeleteInterpreter(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := self.svc.Delete(c.Request.Context(), id); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type Job struct {
|
||||||
|
ID int64
|
||||||
|
|
||||||
|
JobForInsert
|
||||||
|
JobForUpdate
|
||||||
|
}
|
||||||
|
type JobForInsert struct {
|
||||||
|
Command []string
|
||||||
|
Stdin *string
|
||||||
|
}
|
||||||
|
type JobForUpdate struct {
|
||||||
|
Stdout string
|
||||||
|
Stderr string
|
||||||
|
Status int32
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/models"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type JobRepository struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewJobRepository(db *sql.DB) *JobRepository {
|
||||||
|
return &JobRepository{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) Init(ctx context.Context) error {
|
||||||
|
_, err := r.DB.ExecContext(ctx, storage.CreateJobsTable)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) InitJob(ctx context.Context, agentID string, job models.JobForInsert) (int64, error) {
|
||||||
|
commandJSON, err := json.Marshal(job.Command)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("marshal command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdinVal *string
|
||||||
|
if job.Stdin != nil {
|
||||||
|
stdinVal = job.Stdin
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.DB.ExecContext(ctx,
|
||||||
|
`INSERT INTO jobs (agent_id, command, stdin, stdout, stderr, status) VALUES (?, ?, ?, '', '', 0)`,
|
||||||
|
agentID, string(commandJSON), stdinVal,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.LastInsertId()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) UpdateJobInDB(ctx context.Context, jid int64, msg models.JobForUpdate) (models.Job, error) {
|
||||||
|
result, err := r.DB.ExecContext(ctx,
|
||||||
|
`UPDATE jobs SET stdout = ?, stderr = ?, status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
|
msg.Stdout, msg.Stderr, msg.Status, jid,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return models.Job{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return models.Job{}, err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return models.Job{}, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetJobByID(ctx, jid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *JobRepository) GetJobByID(ctx context.Context, jid int64) (models.Job, error) {
|
||||||
|
var job models.Job
|
||||||
|
var commandJSON string
|
||||||
|
var stdinVal *string
|
||||||
|
|
||||||
|
err := r.DB.QueryRowContext(ctx,
|
||||||
|
`SELECT id, command, stdin, stdout, stderr, status FROM jobs WHERE id = ?`,
|
||||||
|
jid,
|
||||||
|
).Scan(&job.ID, &commandJSON, &stdinVal, &job.Stdout, &job.Stderr, &job.Status)
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return models.Job{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return models.Job{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(commandJSON), &job.JobForInsert.Command); err != nil {
|
||||||
|
return models.Job{}, fmt.Errorf("unmarshal command: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
job.JobForInsert.Stdin = stdinVal
|
||||||
|
return job, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogRepository struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogRepository() *LogRepository {
|
||||||
|
return &LogRepository{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) SetDB(db *sql.DB) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.DB = db
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) IsConnected() bool {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return r.DB != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) getDB() *sql.DB {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
return r.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) Init(ctx context.Context) error {
|
||||||
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx, storage.CreateLogsTable)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) Insert(ctx context.Context, log storage.LogEntry) error {
|
||||||
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
INSERT INTO logs (timestamp, level, service, agent, message)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
`, log.Timestamp, log.Level, log.Service, log.Agent, log.Message)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) InsertBatch(ctx context.Context, logs []storage.LogEntry) error {
|
||||||
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(logs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build multi-row INSERT statement
|
||||||
|
query := "INSERT INTO logs (timestamp, level, service, agent, message) VALUES "
|
||||||
|
args := make([]interface{}, 0, len(logs)*5)
|
||||||
|
for i, log := range logs {
|
||||||
|
if i > 0 {
|
||||||
|
query += ", "
|
||||||
|
}
|
||||||
|
query += fmt.Sprintf("($%d, $%d, $%d, $%d, $%d)",
|
||||||
|
i*5+1, i*5+2, i*5+3, i*5+4, i*5+5)
|
||||||
|
args = append(args, log.Timestamp, log.Level, log.Service, log.Agent, log.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.ExecContext(ctx, query, args...)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogFilter struct {
|
||||||
|
Level string
|
||||||
|
Service string
|
||||||
|
Agent string
|
||||||
|
DateFrom time.Time
|
||||||
|
DateTo time.Time
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) Search(ctx context.Context, filter LogFilter) ([]storage.LogEntry, error) {
|
||||||
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return []storage.LogEntry{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query := "SELECT timestamp, level, service, agent, message FROM logs WHERE 1=1"
|
||||||
|
args := make([]interface{}, 0)
|
||||||
|
argIdx := 1
|
||||||
|
|
||||||
|
if filter.Level != "" {
|
||||||
|
query += " AND level = $" + string(rune('0'+argIdx))
|
||||||
|
args = append(args, filter.Level)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Service != "" {
|
||||||
|
query += " AND service = $" + string(rune('0'+argIdx))
|
||||||
|
args = append(args, filter.Service)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Agent != "" {
|
||||||
|
query += " AND agent = $" + string(rune('0'+argIdx))
|
||||||
|
args = append(args, filter.Agent)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filter.DateFrom.IsZero() {
|
||||||
|
query += " AND timestamp >= $" + string(rune('0'+argIdx))
|
||||||
|
args = append(args, filter.DateFrom)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filter.DateTo.IsZero() {
|
||||||
|
query += " AND timestamp <= $" + string(rune('0'+argIdx))
|
||||||
|
args = append(args, filter.DateTo)
|
||||||
|
argIdx++
|
||||||
|
}
|
||||||
|
|
||||||
|
query += " ORDER BY timestamp DESC"
|
||||||
|
|
||||||
|
if filter.Limit > 0 {
|
||||||
|
query += " LIMIT $" + string(rune('0'+argIdx))
|
||||||
|
args = append(args, filter.Limit)
|
||||||
|
argIdx++
|
||||||
|
} else {
|
||||||
|
query += " LIMIT 100"
|
||||||
|
}
|
||||||
|
|
||||||
|
if filter.Offset > 0 {
|
||||||
|
query += " OFFSET $" + string(rune('0'+argIdx))
|
||||||
|
args = append(args, filter.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
logs := make([]storage.LogEntry, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var log storage.LogEntry
|
||||||
|
if err := rows.Scan(&log.Timestamp, &log.Level, &log.Service, &log.Agent, &log.Message); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
logs = append(logs, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
return logs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) GetDistinctServices(ctx context.Context) ([]string, error) {
|
||||||
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, "SELECT DISTINCT service FROM logs ORDER BY service")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
services := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var service string
|
||||||
|
if err := rows.Scan(&service); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
services = append(services, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
return services, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) GetDistinctAgents(ctx context.Context) ([]string, error) {
|
||||||
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, "SELECT DISTINCT agent FROM logs ORDER BY agent")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
agents := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var agent string
|
||||||
|
if err := rows.Scan(&agent); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
agents = append(agents, agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return agents, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) GetDistinctLevels(ctx context.Context) ([]string, error) {
|
||||||
|
db := r.getDB()
|
||||||
|
if db == nil {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, "SELECT DISTINCT level FROM logs ORDER BY level")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
levels := make([]string, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var level string
|
||||||
|
if err := rows.Scan(&level); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
levels = append(levels, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
return levels, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
// Tokens represents a user record with info and permissions.
|
||||||
|
type Tokens struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
PermissionView bool `json:"permission_view"`
|
||||||
|
PermissionManage bool `json:"permission_manage_agent"`
|
||||||
|
PermissionAdmin bool `json:"permission_admin"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenCreate is the request body for creating a new user.
|
||||||
|
type TokenCreate struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
LastName string `json:"last_name" binding:"required"`
|
||||||
|
Login string `json:"login" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
PermissionView bool `json:"permission_view"`
|
||||||
|
PermissionManage bool `json:"permission_manage_agent"`
|
||||||
|
PermissionAdmin bool `json:"permission_admin"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenUpdate is the request body for updating an existing user.
|
||||||
|
type TokenUpdate struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenUpdatePermissions is the request body for updating user permissions.
|
||||||
|
type TokenUpdatePermissions struct {
|
||||||
|
PermissionView *bool `json:"permission_view"`
|
||||||
|
PermissionManage *bool `json:"permission_manage_agent"`
|
||||||
|
PermissionAdmin *bool `json:"permission_admin"`
|
||||||
|
IsActive *bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TokenPasswordReset is the request body for resetting a user's password.
|
||||||
|
type TokenPasswordReset struct {
|
||||||
|
NewPassword string `json:"new_password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchActionRequest is the request body for batch activate/deactivate users.
|
||||||
|
type BatchActionRequest struct {
|
||||||
|
Logins []string `json:"logins" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginRequest is the request body for login.
|
||||||
|
type LoginRequest struct {
|
||||||
|
Login string `json:"login" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginResponse is returned after successful login.
|
||||||
|
type LoginResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
LastName string `json:"last_name"`
|
||||||
|
Login string `json:"login"`
|
||||||
|
PermissionView bool `json:"permission_view"`
|
||||||
|
PermissionManage bool `json:"permission_manage_agent"`
|
||||||
|
PermissionAdmin bool `json:"permission_admin"`
|
||||||
|
IsActive bool `json:"is_active"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationToken represents a one-time agent registration token.
|
||||||
|
type RegistrationToken struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Used bool `json:"used"`
|
||||||
|
CreatedAt *string `json:"created_at"`
|
||||||
|
UsedAt *string `json:"used_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationRequest is the request body for creating a registration token.
|
||||||
|
type RegistrationRequest struct {
|
||||||
|
Label string `json:"label" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistrationResponse is returned when an agent registers.
|
||||||
|
type RegistrationResponse struct {
|
||||||
|
CACert string `json:"ca_cert"`
|
||||||
|
ClientCert string `json:"client_cert"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployType represents the type of agent deployment
|
||||||
|
// @Description Type of deployment: docker or binary
|
||||||
|
type DeployType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
DeployTypeDocker DeployType = "docker"
|
||||||
|
DeployTypeBinary DeployType = "binary"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AuthMethod represents the SSH authentication method
|
||||||
|
// @Description SSH authentication method: key or password
|
||||||
|
type AuthMethod string
|
||||||
|
|
||||||
|
const (
|
||||||
|
AuthMethodKey AuthMethod = "key"
|
||||||
|
AuthMethodPassword AuthMethod = "password"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentDeployConfig represents the configuration for deploying an agent to a server
|
||||||
|
// @Description Configuration for deploying HellreigN agent to a single server
|
||||||
|
type AgentDeployConfig struct {
|
||||||
|
User string `json:"user" binding:"required" example:"admin" description:"SSH username"`
|
||||||
|
IP string `json:"ip" binding:"required" example:"192.168.1.100" description:"Server IP address"`
|
||||||
|
Port int `json:"port" example:"22" description:"SSH port (default: 22)"`
|
||||||
|
AuthMethod AuthMethod `json:"authMethod" binding:"required" example:"key" description:"SSH auth method: key or password"`
|
||||||
|
SSHKey string `json:"sshKey,omitempty" example:"-----BEGIN OPENSSH PRIVATE KEY-----" description:"SSH private key (required if authMethod=key)"`
|
||||||
|
Password string `json:"password,omitempty" example:"secret" description:"SSH password (required if authMethod=password)"`
|
||||||
|
DeployType DeployType `json:"deployType" binding:"required" example:"docker" description:"Deployment type: docker or binary"`
|
||||||
|
AgentLabel string `json:"agentLabel" binding:"required" example:"production-server-1" description:"Unique label for the agent"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployAgentsRequest represents the request body for deploying agents to multiple servers
|
||||||
|
// @Description Request to deploy HellreigN agents to multiple servers
|
||||||
|
type DeployAgentsRequest struct {
|
||||||
|
Servers []AgentDeployConfig `json:"servers" binding:"required,min=1,dive" description:"List of server configurations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployResponse represents the response after deploying agents
|
||||||
|
// @Description Response containing deployment results and registration tokens
|
||||||
|
type DeployResponse struct {
|
||||||
|
Message string `json:"message" example:"Deployment completed"`
|
||||||
|
Results []DeployResult `json:"results" description:"Deployment results for each server"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeployResult represents the result of deploying to a single server
|
||||||
|
// @Description Result of deploying to a single server
|
||||||
|
type DeployResult struct {
|
||||||
|
IP string `json:"ip" example:"192.168.1.100" description:"Server IP address"`
|
||||||
|
AgentLabel string `json:"agent_label" example:"production-server-1" description:"Agent label"`
|
||||||
|
Token string `json:"token" example:"abc123..." description:"Registration token for agent registration"`
|
||||||
|
Success bool `json:"success" example:"true" description:"Whether deployment succeeded"`
|
||||||
|
Error string `json:"error,omitempty" example:"" description:"Error message if deployment failed"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/utils"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Repository wraps a SQLite database connection.
|
||||||
|
type Repository struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Repository.
|
||||||
|
func New(db *sql.DB) *Repository {
|
||||||
|
return &Repository{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
var ErrAccountInactive = errors.New("account is not activated")
|
||||||
|
|
||||||
|
// Init creates the tokens table if it does not exist.
|
||||||
|
func (r *Repository) Init() error {
|
||||||
|
_, err := r.DB.Exec(storage.CreateSqlite)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Migration: add is_active column if it doesn't exist (SQLite ignores errors for duplicate column)
|
||||||
|
_, _ = r.DB.Exec(storage.AddIsActiveColumn)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateToken inserts a new user record with hashed password and generated token.
|
||||||
|
// New users are created with is_active=false by default.
|
||||||
|
func (r *Repository) CreateToken(tc TokenCreate) (string, error) {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(tc.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := utils.RandomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`INSERT INTO tokens (name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
tc.Name, tc.LastName, tc.Login, string(hashed), token,
|
||||||
|
tc.PermissionView, tc.PermissionManage, tc.PermissionAdmin, tc.IsActive,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strconv.FormatInt(id, 10), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login authenticates by login/password, generates a new token, and returns LoginResponse.
|
||||||
|
func (r *Repository) Login(login, password string) (*LoginResponse, error) {
|
||||||
|
var t Tokens
|
||||||
|
var hashedPassword string
|
||||||
|
|
||||||
|
err := r.DB.QueryRow(
|
||||||
|
`SELECT id, name, last_name, login, password, token, permission_view, permission_manage_agent, permission_admin, is_active
|
||||||
|
FROM tokens WHERE login = ?`,
|
||||||
|
login,
|
||||||
|
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &hashedPassword, &t.Token,
|
||||||
|
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)); err != nil {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
if !t.IsActive {
|
||||||
|
return nil, ErrAccountInactive
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate new token on each login
|
||||||
|
newToken, err := utils.RandomToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.DB.Exec(`UPDATE tokens SET token = ? WHERE id = ?`, newToken, t.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &LoginResponse{
|
||||||
|
Token: newToken,
|
||||||
|
Name: t.Name,
|
||||||
|
LastName: t.LastName,
|
||||||
|
Login: t.Login,
|
||||||
|
PermissionView: t.PermissionView,
|
||||||
|
PermissionManage: t.PermissionManage,
|
||||||
|
PermissionAdmin: t.PermissionAdmin,
|
||||||
|
IsActive: t.IsActive,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenByToken retrieves a user record by token value.
|
||||||
|
func (r *Repository) GetToken(token string) (*Tokens, error) {
|
||||||
|
var t Tokens
|
||||||
|
err := r.DB.QueryRow(
|
||||||
|
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin
|
||||||
|
FROM tokens WHERE token = ?`,
|
||||||
|
token,
|
||||||
|
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
|
||||||
|
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTokens returns all users without password and token.
|
||||||
|
func (r *Repository) ListTokens() ([]Tokens, error) {
|
||||||
|
rows, err := r.DB.Query(
|
||||||
|
`SELECT id, name, last_name, login, permission_view, permission_manage_agent, permission_admin
|
||||||
|
FROM tokens`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tokens []Tokens
|
||||||
|
for rows.Next() {
|
||||||
|
var t Tokens
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.LastName, &t.Login,
|
||||||
|
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tokens = append(tokens, t)
|
||||||
|
}
|
||||||
|
return tokens, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteToken deletes a user by token value.
|
||||||
|
func (r *Repository) DeleteToken(token string) error {
|
||||||
|
result, err := r.DB.Exec(`DELETE FROM tokens WHERE token = ?`, token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTokenByLogin deletes a user by login.
|
||||||
|
func (r *Repository) DeleteTokenByLogin(login string) error {
|
||||||
|
result, err := r.DB.Exec(`DELETE FROM tokens WHERE login = ?`, login)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExistsByLogin checks if a user with given login exists.
|
||||||
|
func (r *Repository) ExistsByLogin(login string) bool {
|
||||||
|
var count int
|
||||||
|
err := r.DB.QueryRow(`SELECT COUNT(*) FROM tokens WHERE login = ?`, login).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitRegistrationTokens creates the registration_tokens table if it does not exist.
|
||||||
|
func (r *Repository) InitRegistrationTokens() error {
|
||||||
|
_, err := r.DB.Exec(storage.CreateRegistrationTokensTable)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRegistrationToken inserts a new one-time registration token.
|
||||||
|
func (r *Repository) CreateRegistrationToken(label string) (string, error) {
|
||||||
|
token, err := utils.RandomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = r.DB.Exec(
|
||||||
|
`INSERT INTO registration_tokens (token, label, used) VALUES (?, ?, 0)`,
|
||||||
|
token, label,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRegistrationToken retrieves a registration token if it exists and is not used.
|
||||||
|
func (r *Repository) GetRegistrationToken(token string) (*RegistrationToken, error) {
|
||||||
|
var rt RegistrationToken
|
||||||
|
err := r.DB.QueryRow(
|
||||||
|
`SELECT id, token, label, used, created_at, used_at FROM registration_tokens WHERE token = ?`,
|
||||||
|
token,
|
||||||
|
).Scan(&rt.ID, &rt.Token, &rt.Label, &rt.Used, &rt.CreatedAt, &rt.UsedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkRegistrationTokenUsed marks a registration token as used.
|
||||||
|
func (r *Repository) MarkRegistrationTokenUsed(token string) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE registration_tokens SET used = 1, used_at = CURRENT_TIMESTAMP WHERE token = ? AND used = 0`,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateToken activates a user by token value.
|
||||||
|
func (r *Repository) ActivateToken(token string) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET is_active = 1 WHERE token = ?`,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateToken deactivates a user by token value.
|
||||||
|
func (r *Repository) DeactivateToken(token string) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET is_active = 0 WHERE token = ?`,
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActivateUserByLogin activates a user by login.
|
||||||
|
func (r *Repository) ActivateUserByLogin(login string) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET is_active = 1 WHERE login = ?`,
|
||||||
|
login,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateUserByLogin deactivates a user by login.
|
||||||
|
func (r *Repository) DeactivateUserByLogin(login string) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET is_active = 0 WHERE login = ?`,
|
||||||
|
login,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListInactiveTokens returns all users that are not activated.
|
||||||
|
func (r *Repository) ListInactiveTokens() ([]Tokens, error) {
|
||||||
|
rows, err := r.DB.Query(
|
||||||
|
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin, is_active
|
||||||
|
FROM tokens WHERE is_active = 0`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tokens []Tokens
|
||||||
|
for rows.Next() {
|
||||||
|
var t Tokens
|
||||||
|
if err := rows.Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
|
||||||
|
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tokens = append(tokens, t)
|
||||||
|
}
|
||||||
|
return tokens, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenByLogin retrieves a user by login.
|
||||||
|
func (r *Repository) GetTokenByLogin(login string) (*Tokens, error) {
|
||||||
|
var t Tokens
|
||||||
|
err := r.DB.QueryRow(
|
||||||
|
`SELECT id, name, last_name, login, token, permission_view, permission_manage_agent, permission_admin, is_active
|
||||||
|
FROM tokens WHERE login = ?`,
|
||||||
|
login,
|
||||||
|
).Scan(&t.ID, &t.Name, &t.LastName, &t.Login, &t.Token,
|
||||||
|
&t.PermissionView, &t.PermissionManage, &t.PermissionAdmin, &t.IsActive)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateToken updates name and last_name for a user by login.
|
||||||
|
func (r *Repository) UpdateToken(login string, update TokenUpdate) error {
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET name = ?, last_name = ? WHERE login = ?`,
|
||||||
|
update.Name, update.LastName, login,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePermissions updates permissions and is_active for a user by login.
|
||||||
|
func (r *Repository) UpdatePermissions(login string, update TokenUpdatePermissions) error {
|
||||||
|
user, err := r.GetTokenByLogin(login)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use existing values if not provided
|
||||||
|
newView := user.PermissionView
|
||||||
|
newManage := user.PermissionManage
|
||||||
|
newAdmin := user.PermissionAdmin
|
||||||
|
newActive := user.IsActive
|
||||||
|
|
||||||
|
if update.PermissionView != nil {
|
||||||
|
newView = *update.PermissionView
|
||||||
|
}
|
||||||
|
if update.PermissionManage != nil {
|
||||||
|
newManage = *update.PermissionManage
|
||||||
|
}
|
||||||
|
if update.PermissionAdmin != nil {
|
||||||
|
newAdmin = *update.PermissionAdmin
|
||||||
|
}
|
||||||
|
if update.IsActive != nil {
|
||||||
|
newActive = *update.IsActive
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET permission_view = ?, permission_manage_agent = ?, permission_admin = ?, is_active = ? WHERE login = ?`,
|
||||||
|
newView, newManage, newAdmin, newActive, login,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePassword updates the password for a user by login.
|
||||||
|
func (r *Repository) UpdatePassword(login string, newPassword string) error {
|
||||||
|
hashed, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.DB.Exec(
|
||||||
|
`UPDATE tokens SET password = ? WHERE login = ?`,
|
||||||
|
string(hashed), login,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScriptInterpreter struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptInterpreterCreate struct {
|
||||||
|
Name string `json:"name" binding:"required"`
|
||||||
|
Label string `json:"label" binding:"required"`
|
||||||
|
Argv []string `json:"argv" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptInterpreterUpdate struct {
|
||||||
|
Name *string `json:"name"`
|
||||||
|
Label *string `json:"label"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScriptInterpreterRepo struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptInterpreterRepo(db *sql.DB) *ScriptInterpreterRepo {
|
||||||
|
return &ScriptInterpreterRepo{DB: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) Init(ctx context.Context) error {
|
||||||
|
_, err := r.DB.ExecContext(ctx, storage.CreateScriptInterpretersTable)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) Create(ctx context.Context, in ScriptInterpreterCreate) (*ScriptInterpreter, error) {
|
||||||
|
argvJSON, err := json.Marshal(in.Argv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := r.DB.ExecContext(ctx,
|
||||||
|
`INSERT INTO script_interpreters (name, label, argv) VALUES (?, ?, ?)`,
|
||||||
|
in.Name, in.Label, string(argvJSON),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) GetByID(ctx context.Context, id int64) (*ScriptInterpreter, error) {
|
||||||
|
var si ScriptInterpreter
|
||||||
|
var argvJSON string
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
|
||||||
|
err := r.DB.QueryRowContext(ctx,
|
||||||
|
`SELECT id, name, label, argv, created_at, updated_at FROM script_interpreters WHERE id = ?`,
|
||||||
|
id,
|
||||||
|
).Scan(&si.ID, &si.Name, &si.Label, &argvJSON, &createdAt, &updatedAt)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(argvJSON), &si.Argv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
si.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
si.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||||
|
return &si, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) List(ctx context.Context) ([]ScriptInterpreter, error) {
|
||||||
|
rows, err := r.DB.QueryContext(ctx,
|
||||||
|
`SELECT id, name, label, argv, created_at, updated_at FROM script_interpreters`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var interpreters []ScriptInterpreter
|
||||||
|
for rows.Next() {
|
||||||
|
var si ScriptInterpreter
|
||||||
|
var argvJSON, createdAt, updatedAt string
|
||||||
|
if err := rows.Scan(&si.ID, &si.Name, &si.Label, &argvJSON, &createdAt, &updatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(argvJSON), &si.Argv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
si.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
si.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||||
|
interpreters = append(interpreters, si)
|
||||||
|
}
|
||||||
|
return interpreters, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) Update(ctx context.Context, id int64, in ScriptInterpreterUpdate) (*ScriptInterpreter, error) {
|
||||||
|
si, err := r.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
set := ""
|
||||||
|
args := make([]interface{}, 0)
|
||||||
|
idx := 1
|
||||||
|
|
||||||
|
if in.Name != nil {
|
||||||
|
set += "name = ?"
|
||||||
|
args = append(args, *in.Name)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
if in.Label != nil {
|
||||||
|
if idx > 1 {
|
||||||
|
set += ", "
|
||||||
|
}
|
||||||
|
set += "label = ?"
|
||||||
|
args = append(args, *in.Label)
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
if in.Argv != nil {
|
||||||
|
if idx > 1 {
|
||||||
|
set += ", "
|
||||||
|
}
|
||||||
|
argvJSON, err := json.Marshal(in.Argv)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
set += "argv = ?"
|
||||||
|
args = append(args, string(argvJSON))
|
||||||
|
idx++
|
||||||
|
}
|
||||||
|
|
||||||
|
if idx == 1 {
|
||||||
|
return si, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
set += ", updated_at = CURRENT_TIMESTAMP"
|
||||||
|
args = append(args, id)
|
||||||
|
|
||||||
|
_, err = r.DB.ExecContext(ctx,
|
||||||
|
`UPDATE script_interpreters SET `+set+` WHERE id = ?`,
|
||||||
|
args...,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ScriptInterpreterRepo) Delete(ctx context.Context, id int64) error {
|
||||||
|
result, err := r.DB.ExecContext(ctx,
|
||||||
|
`DELETE FROM script_interpreters WHERE id = ?`,
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
affected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if affected == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ScriptService struct {
|
||||||
|
repo *repository.ScriptInterpreterRepo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScriptService(repo *repository.ScriptInterpreterRepo) *ScriptService {
|
||||||
|
return &ScriptService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveCommand builds the full argv[] by prepending the interpreter's argv
|
||||||
|
// to the script text (as the last argument).
|
||||||
|
func (self *ScriptService) ResolveCommand(ctx context.Context, interpreterID int64, scriptText string) ([]string, error) {
|
||||||
|
interpreter, err := self.repo.GetByID(ctx, interpreterID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(interpreter.Argv) == 0 {
|
||||||
|
return nil, fmt.Errorf("interpreter %q has empty argv", interpreter.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
argv := make([]string, len(interpreter.Argv)+1)
|
||||||
|
copy(argv, interpreter.Argv)
|
||||||
|
argv[len(argv)-1] = scriptText
|
||||||
|
return argv, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *ScriptService) Create(ctx context.Context, in repository.ScriptInterpreterCreate) (*repository.ScriptInterpreter, error) {
|
||||||
|
return self.repo.Create(ctx, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *ScriptService) GetByID(ctx context.Context, id int64) (*repository.ScriptInterpreter, error) {
|
||||||
|
return self.repo.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *ScriptService) List(ctx context.Context) ([]repository.ScriptInterpreter, error) {
|
||||||
|
return self.repo.List(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *ScriptService) Update(ctx context.Context, id int64, in repository.ScriptInterpreterUpdate) (*repository.ScriptInterpreter, error) {
|
||||||
|
return self.repo.Update(ctx, id, in)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (self *ScriptService) Delete(ctx context.Context, id int64) error {
|
||||||
|
return self.repo.Delete(ctx, id)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/ClickHouse/clickhouse-go/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClickHouseConfig struct {
|
||||||
|
Host string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Database string
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenClickHouse(cfg ClickHouseConfig) (*sql.DB, error) {
|
||||||
|
dsn := fmt.Sprintf("clickhouse://%s:%s@%s/%s",
|
||||||
|
cfg.User, cfg.Password, cfg.Host, cfg.Database)
|
||||||
|
|
||||||
|
db, err := sql.Open("clickhouse", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("clickhouse open: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db.SetMaxOpenConns(5)
|
||||||
|
db.SetMaxIdleConns(2)
|
||||||
|
db.SetConnMaxLifetime(10 * time.Minute)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := db.PingContext(ctx); err != nil {
|
||||||
|
db.Close()
|
||||||
|
return nil, fmt.Errorf("clickhouse ping: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("ClickHouse connected via database/sql: %s", cfg.Host)
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenClickHouseWithRetry attempts to connect to ClickHouse with retries and backoff.
|
||||||
|
func OpenClickHouseWithRetry(cfg ClickHouseConfig, maxRetries int, initialDelay time.Duration) (*sql.DB, error) {
|
||||||
|
var lastErr error
|
||||||
|
delay := initialDelay
|
||||||
|
|
||||||
|
for i := 0; i < maxRetries; i++ {
|
||||||
|
db, err := OpenClickHouse(cfg)
|
||||||
|
if err == nil {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
log.Printf("ClickHouse connection attempt %d/%d failed: %v, retrying in %v...", i+1, maxRetries, err, delay)
|
||||||
|
time.Sleep(delay)
|
||||||
|
delay *= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("clickhouse connection failed after %d attempts: %w", maxRetries, lastErr)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type LogEntry struct {
|
||||||
|
Timestamp time.Time `ch:"timestamp"`
|
||||||
|
Level string `ch:"level"`
|
||||||
|
Service string `ch:"service"`
|
||||||
|
Agent string `ch:"agent"`
|
||||||
|
Message string `ch:"message"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
const CreateSqlite = `
|
||||||
|
CREATE TABLE IF NOT EXISTS tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL,
|
||||||
|
login TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
permission_view BOOL NOT NULL,
|
||||||
|
permission_manage_agent BOOL NOT NULL,
|
||||||
|
permission_admin BOOL NOT NULL,
|
||||||
|
is_active BOOL NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
// AddIsActiveColumn adds is_active column to tokens table if it doesn't exist.
|
||||||
|
// This is a migration for existing databases that don't have this column.
|
||||||
|
const AddIsActiveColumn = `
|
||||||
|
ALTER TABLE tokens ADD COLUMN is_active BOOL NOT NULL DEFAULT 0
|
||||||
|
`
|
||||||
|
|
||||||
|
const CreateRegistrationTokensTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS registration_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
used BOOL NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
used_at DATETIME
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
const CreateJobsTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
agent_id TEXT NOT NULL,
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
stdin TEXT,
|
||||||
|
stdout TEXT DEFAULT '',
|
||||||
|
stderr TEXT DEFAULT '',
|
||||||
|
status INTEGER DEFAULT 0,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
const CreateScriptInterpretersTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS script_interpreters (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
argv TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
const CreateLogsTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
|
timestamp DateTime64(3) DEFAULT now(),
|
||||||
|
level LowCardinality(String),
|
||||||
|
service LowCardinality(String),
|
||||||
|
agent LowCardinality(String),
|
||||||
|
message String
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
ORDER BY (timestamp, level, service, agent)
|
||||||
|
TTL timestamp + INTERVAL 30 DAY
|
||||||
|
SETTINGS index_granularity = 8192
|
||||||
|
`
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pragmas = map[string]string{
|
||||||
|
`journal_mode`: `wal`,
|
||||||
|
`synchronous`: `normal`,
|
||||||
|
`busy_timeout`: `30000`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSqliteDsn(path string, pragmas map[string]string) string {
|
||||||
|
pragmastrs := make([]string, len(pragmas))
|
||||||
|
i := 0
|
||||||
|
for k, v := range pragmas {
|
||||||
|
pragmastrs[i] = (fmt.Sprintf(`pragma=%s(%s)`, k, v))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return path + "?" + "mode=rwc&" + strings.Join(pragmastrs, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Open(path string) (*sql.DB, error) {
|
||||||
|
dsn := buildSqliteDsn(path, pragmas)
|
||||||
|
db, err := sql.Open("sqlite", dsn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
if _, err := db.Exec(CreateSqlite); err != nil {
|
||||||
|
return nil, fmt.Errorf("migrate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration: add is_active column if it doesn't exist
|
||||||
|
_, _ = db.Exec(AddIsActiveColumn)
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CertBundle holds CA and server certificates loaded from disk.
|
||||||
|
type CertBundle struct {
|
||||||
|
CACert *x509.Certificate
|
||||||
|
CAKey *rsa.PrivateKey
|
||||||
|
ServerCert *x509.Certificate
|
||||||
|
ServerKey *rsa.PrivateKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCertBundle loads CA and server certificates from the given directory.
|
||||||
|
func LoadCertBundle(certDir string) (*CertBundle, error) {
|
||||||
|
caCertPEM, err := os.ReadFile(filepath.Join(certDir, "ca.crt"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read ca.crt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caKeyPEM, err := os.ReadFile(filepath.Join(certDir, "ca.key"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read ca.key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverCertPEM, err := os.ReadFile(filepath.Join(certDir, "server.crt"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read server.crt: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serverKeyPEM, err := os.ReadFile(filepath.Join(certDir, "server.key"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("read server.key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caCert := decodeCert(caCertPEM)
|
||||||
|
caKey, err := decodeRSAPrivateKey(caKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ca.key: %w", err)
|
||||||
|
}
|
||||||
|
serverCert := decodeCert(serverCertPEM)
|
||||||
|
serverKey, err := decodeRSAPrivateKey(serverKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse server.key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &CertBundle{
|
||||||
|
CACert: caCert,
|
||||||
|
CAKey: caKey,
|
||||||
|
ServerCert: serverCert,
|
||||||
|
ServerKey: serverKey,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignCSR signs a client CSR with the CA and returns the client certificate PEM.
|
||||||
|
func (b *CertBundle) SignCSR(csrPEM []byte, label string) ([]byte, error) {
|
||||||
|
csr := decodeCSR(csrPEM)
|
||||||
|
|
||||||
|
// Verify CSR signature
|
||||||
|
if err := csr.CheckSignature(); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid CSR signature: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("generate serial: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
template := x509.Certificate{
|
||||||
|
SerialNumber: serialNumber,
|
||||||
|
Subject: pkix.Name{
|
||||||
|
CommonName: label,
|
||||||
|
Organization: csr.Subject.Organization,
|
||||||
|
},
|
||||||
|
NotBefore: now,
|
||||||
|
NotAfter: now.Add(365 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
|
||||||
|
BasicConstraintsValid: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, &template, b.CACert, csr.PublicKey, b.CAKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create certificate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: certDER,
|
||||||
|
})
|
||||||
|
|
||||||
|
return certPEM, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCACertPEM returns the CA certificate as PEM bytes.
|
||||||
|
func (b *CertBundle) GetCACertPEM() []byte {
|
||||||
|
return pem.EncodeToMemory(&pem.Block{
|
||||||
|
Type: "CERTIFICATE",
|
||||||
|
Bytes: b.CACert.Raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCert(pemData []byte) *x509.Certificate {
|
||||||
|
block, _ := pem.Decode(pemData)
|
||||||
|
if block == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeRSAPrivateKey(pemData []byte) (*rsa.PrivateKey, error) {
|
||||||
|
block, _ := pem.Decode(pemData)
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("no PEM block found")
|
||||||
|
}
|
||||||
|
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
// Try PKCS1 fallback
|
||||||
|
key, err = x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse PKCS1: %w", err)
|
||||||
|
}
|
||||||
|
return key.(*rsa.PrivateKey), nil
|
||||||
|
}
|
||||||
|
rsaKey, ok := key.(*rsa.PrivateKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("key is not RSA, got %T", key)
|
||||||
|
}
|
||||||
|
return rsaKey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeCSR(pemData []byte) *x509.CertificateRequest {
|
||||||
|
block, _ := pem.Decode(pemData)
|
||||||
|
if block == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
csr, err := x509.ParseCertificateRequest(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return csr
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RandomToken() (string, error) {
|
||||||
|
token := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(token); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(token), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
.PHONY: docs lint
|
||||||
|
docs:
|
||||||
|
swag init -g ./cmd/main.go --parseDependency --parseInternal
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run --fix
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Скрипт генерации SSL сертификатов для mTLS gRPC
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
CERT_DIR="${1:-/etc/HellreigN/ssl}"
|
||||||
|
DAYS_VALID=365
|
||||||
|
|
||||||
|
echo "Generating CA and server certificates in ${CERT_DIR}..."
|
||||||
|
|
||||||
|
# Создаём директорию
|
||||||
|
mkdir -p "${CERT_DIR}"
|
||||||
|
|
||||||
|
# Если сертификаты уже есть и не пустые - не перегенерируем
|
||||||
|
if [ -s "${CERT_DIR}/ca.crt" ] && [ -s "${CERT_DIR}/server.crt" ] && [ -s "${CERT_DIR}/server.key" ]; then
|
||||||
|
echo "Certificates already exist, skipping generation."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Если файлы существуют но пустые - удаляем их для перегенерации
|
||||||
|
rm -f "${CERT_DIR}/ca.crt" "${CERT_DIR}/ca.key" "${CERT_DIR}/server.crt" "${CERT_DIR}/server.key" "${CERT_DIR}/server.csr"
|
||||||
|
|
||||||
|
# Генерация CA
|
||||||
|
echo "Generating CA..."
|
||||||
|
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out "${CERT_DIR}/ca.key"
|
||||||
|
openssl req -x509 -new -nodes -sha256 -days ${DAYS_VALID} \
|
||||||
|
-key "${CERT_DIR}/ca.key" \
|
||||||
|
-out "${CERT_DIR}/ca.crt" \
|
||||||
|
-subj "/CN=HellreigN Root CA"
|
||||||
|
|
||||||
|
# Генерация серверного сертификата
|
||||||
|
echo "Generating server certificate..."
|
||||||
|
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out "${CERT_DIR}/server.key"
|
||||||
|
openssl req -new -sha256 \
|
||||||
|
-key "${CERT_DIR}/server.key" \
|
||||||
|
-out "${CERT_DIR}/server.csr" \
|
||||||
|
-subj "/CN=${SERVER_CN:-localhost}"
|
||||||
|
|
||||||
|
# Создаём конфиг для server SAN
|
||||||
|
# Поддержка переменных окружения:
|
||||||
|
# SERVER_SAN_DNS - список DNS имен через запятую (например: localhost,backend,myserver.example.com)
|
||||||
|
# SERVER_SAN_IP - список IP адресов через запятую (например: 127.0.0.1,192.168.1.100)
|
||||||
|
cat > "${CERT_DIR}/server.ext" <<EOF
|
||||||
|
authorityKeyIdentifier=keyid,issuer
|
||||||
|
basicConstraints=CA:FALSE
|
||||||
|
keyUsage = digitalSignature, keyEncipherment
|
||||||
|
extendedKeyUsage = serverAuth
|
||||||
|
subjectAltName = @alt_names
|
||||||
|
|
||||||
|
[alt_names]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Добавляем DNS SAN
|
||||||
|
dns_idx=1
|
||||||
|
IFS=',' read -ra DNS_NAMES <<< "${SERVER_SAN_DNS:-localhost,backend}"
|
||||||
|
for dns_name in "${DNS_NAMES[@]}"; do
|
||||||
|
dns_name=$(echo "$dns_name" | xargs) # trim whitespace
|
||||||
|
if [ -n "$dns_name" ]; then
|
||||||
|
echo "DNS.${dns_idx} = ${dns_name}" >> "${CERT_DIR}/server.ext"
|
||||||
|
((dns_idx++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Добавляем wildcard для localhost если есть
|
||||||
|
echo "DNS.${dns_idx} = *.localhost" >> "${CERT_DIR}/server.ext"
|
||||||
|
((dns_idx++))
|
||||||
|
|
||||||
|
# Добавляем IP SAN
|
||||||
|
ip_idx=1
|
||||||
|
IFS=',' read -ra IP_ADDRS <<< "${SERVER_SAN_IP:-127.0.0.1}"
|
||||||
|
for ip_addr in "${IP_ADDRS[@]}"; do
|
||||||
|
ip_addr=$(echo "$ip_addr" | xargs) # trim whitespace
|
||||||
|
if [ -n "$ip_addr" ]; then
|
||||||
|
echo "IP.${ip_idx} = ${ip_addr}" >> "${CERT_DIR}/server.ext"
|
||||||
|
((ip_idx++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
openssl x509 -req -sha256 -days ${DAYS_VALID} \
|
||||||
|
-in "${CERT_DIR}/server.csr" \
|
||||||
|
-CA "${CERT_DIR}/ca.crt" \
|
||||||
|
-CAkey "${CERT_DIR}/ca.key" \
|
||||||
|
-CAcreateserial \
|
||||||
|
-out "${CERT_DIR}/server.crt" \
|
||||||
|
-extfile "${CERT_DIR}/server.ext"
|
||||||
|
|
||||||
|
# Очистка лишних файлов
|
||||||
|
rm -f "${CERT_DIR}/server.ext"
|
||||||
|
|
||||||
|
# Установка прав
|
||||||
|
chmod 600 "${CERT_DIR}"/*.key
|
||||||
|
chmod 644 "${CERT_DIR}"/*.crt
|
||||||
|
|
||||||
|
echo "Certificates generated successfully!"
|
||||||
|
echo " CA: ${CERT_DIR}/ca.crt"
|
||||||
|
echo " Server: ${CERT_DIR}/server.crt + ${CERT_DIR}/server.key"
|
||||||
|
echo " SAN DNS: ${SERVER_SAN_DNS:-localhost,backend}"
|
||||||
|
echo " SAN IP: ${SERVER_SAN_IP:-127.0.0.1}"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
FROM node:25-alpine3.23 AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN yarn build
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
location /api/ {
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Gzip сжатие
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||||
|
}
|
||||||
Generated
-7152
File diff suppressed because it is too large
Load Diff
@@ -11,24 +11,19 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-sql": "^6.10.0",
|
"@codemirror/lang-sql": "^6.10.0",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
"@uiw/react-codemirror": "^4.25.8",
|
"@uiw/react-codemirror": "^4.25.8",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"file-surf": "^1.0.3",
|
|
||||||
"framer-motion": "^12.38.0",
|
"framer-motion": "^12.38.0",
|
||||||
"monaco-languageclient": "^10.7.0",
|
|
||||||
"primeicons": "^7.0.0",
|
"primeicons": "^7.0.0",
|
||||||
"primereact": "^10.9.7",
|
"primereact": "^10.9.7",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-force-graph-2d": "^1.29.1",
|
|
||||||
"react-icons": "^5.6.0",
|
"react-icons": "^5.6.0",
|
||||||
"react-router-dom": "^7.13.1",
|
"react-router-dom": "^7.13.1",
|
||||||
"recharts": "^3.8.0",
|
"recharts": "^3.8.0",
|
||||||
"tailwind": "^4.0.0",
|
"tailwind": "^4.0.0",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
"vscode-ws-jsonrpc": "^3.5.0",
|
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { useState, useEffect, type ReactNode } from "react";
|
|
||||||
import { Sidebar } from "@/app/providers/layout/sidebar/sidebar";
|
|
||||||
import { Navigation } from "@/app/providers/layout/navigation/navigation";
|
|
||||||
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
|
||||||
|
|
||||||
export const Layout = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [isOpen, setOpen] = useState(true);
|
|
||||||
const { fetchAgents } = useAgentStore();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAgents();
|
|
||||||
}, [fetchAgents]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
fetchAgents();
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [fetchAgents]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen overflow-hidden" style={{ backgroundColor: "var(--bg-primary)" }}>
|
|
||||||
<Sidebar isOpen={isOpen} onToggle={() => setOpen(!isOpen)} />
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
||||||
<Navigation />
|
|
||||||
<div className="flex-1 overflow-auto p-4">{children}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
import { useNavigate, useLocation } from "react-router-dom";
|
|
||||||
import {
|
|
||||||
FaHome,
|
|
||||||
FaServer,
|
|
||||||
FaPalette,
|
|
||||||
FaUser,
|
|
||||||
FaUsers,
|
|
||||||
FaRocket,
|
|
||||||
FaKey,
|
|
||||||
FaFileAlt,
|
|
||||||
} from "react-icons/fa";
|
|
||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
|
||||||
|
|
||||||
export const Navigation = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const { user, logout } = useAuthStore();
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ path: "/", label: "Главная", icon: FaHome },
|
|
||||||
{ path: "/add-agents", label: "Деплой", icon: FaRocket },
|
|
||||||
{ path: "/registration", label: "Регистрация", icon: FaKey },
|
|
||||||
{ path: "/logs", label: "Логи", icon: FaFileAlt },
|
|
||||||
{ path: "/admin", label: "Админка", icon: FaUsers, adminOnly: true },
|
|
||||||
{ path: "/themes", label: "Темы", icon: FaPalette },
|
|
||||||
];
|
|
||||||
|
|
||||||
const isActive = (path: string) => location.pathname === path;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 border-b"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between px-4 py-2.5">
|
|
||||||
{/* Логотип */}
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 cursor-pointer"
|
|
||||||
onClick={() => navigate("/")}
|
|
||||||
>
|
|
||||||
<FaServer style={{ color: "var(--accent)", fontSize: "18px" }} />
|
|
||||||
<span
|
|
||||||
className="text-sm font-semibold"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
HellreigN
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Навигация */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{navItems
|
|
||||||
.filter((item) => {
|
|
||||||
if (item.adminOnly && !user?.permission_admin) return false;
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const active = isActive(item.path);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.path}
|
|
||||||
onClick={() => navigate(item.path)}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: active ? "var(--accent)" : "transparent",
|
|
||||||
color: active
|
|
||||||
? "var(--accent-text)"
|
|
||||||
: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!active) {
|
|
||||||
e.currentTarget.style.backgroundColor =
|
|
||||||
"var(--bg-secondary)";
|
|
||||||
e.currentTarget.style.color = "var(--text-primary)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!active) {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
e.currentTarget.style.color = "var(--text-secondary)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon size={12} />
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Профиль пользователя */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{user && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
|
||||||
>
|
|
||||||
<FaUser size={12} style={{ color: "var(--accent)" }} />
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className="text-xs"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
{user.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
logout();
|
|
||||||
navigate("/auth");
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--error-bg)",
|
|
||||||
color: "var(--error-text)",
|
|
||||||
border: "1px solid var(--error-border)",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "var(--error-text)";
|
|
||||||
e.currentTarget.style.color = "#fff";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "var(--error-bg)";
|
|
||||||
e.currentTarget.style.color = "var(--error-text)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Выйти
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,294 +0,0 @@
|
|||||||
import React, { useMemo, useState } from "react";
|
|
||||||
import { FaBars, FaMicrochip, FaTimes, FaSpinner, FaCopy, FaCheck } from "react-icons/fa";
|
|
||||||
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
|
||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
isOpen?: boolean;
|
|
||||||
onToggle?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({ isOpen = true, onToggle }) => {
|
|
||||||
const { agents, isLoading, error, fetchAgents } = useAgentStore();
|
|
||||||
const { token } = useAuthStore();
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
|
||||||
|
|
||||||
const filteredAgents = useMemo(() => {
|
|
||||||
if (!searchQuery) return agents;
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
return agents.filter(
|
|
||||||
(agent) =>
|
|
||||||
agent.name.toLowerCase().includes(query) ||
|
|
||||||
agent.services.some((s) => s.name.toLowerCase().includes(query))
|
|
||||||
);
|
|
||||||
}, [agents, searchQuery]);
|
|
||||||
|
|
||||||
const handleCopyToken = () => {
|
|
||||||
if (token) {
|
|
||||||
navigator.clipboard.writeText(token);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="fixed top-4 left-4 z-50 p-2.5 rounded-lg shadow-lg transition-colors md:hidden"
|
|
||||||
style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)" }}
|
|
||||||
aria-label="Открыть sidebar"
|
|
||||||
>
|
|
||||||
<FaBars size={18} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Overlay для мобильных */}
|
|
||||||
<div className="fixed inset-0 bg-black/50 z-40 md:hidden" onClick={onToggle} />
|
|
||||||
|
|
||||||
<aside
|
|
||||||
className={`fixed md:relative w-72 h-screen z-50 transition-transform duration-300 ease-in-out flex flex-col ${
|
|
||||||
isOpen ? "translate-x-0" : "-translate-x-full md:translate-x-0"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderRight: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-4 py-3 border-b"
|
|
||||||
style={{ borderColor: "var(--border)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FaMicrochip style={{ color: "var(--accent)", fontSize: "18px" }} />
|
|
||||||
<h2 className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
|
|
||||||
Агенты
|
|
||||||
</h2>
|
|
||||||
<span
|
|
||||||
className="text-xs px-1.5 py-0.5 rounded"
|
|
||||||
style={{ backgroundColor: "var(--bg-secondary)", color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
{agents.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={onToggle}
|
|
||||||
className="p-1 rounded transition-colors md:hidden"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
<FaTimes size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Поиск */}
|
|
||||||
<div className="px-3 py-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Поиск агентов..."
|
|
||||||
className="w-full px-3 py-2 rounded-lg border text-sm focus:outline-none transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = "var(--border-focus)";
|
|
||||||
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = "var(--border)";
|
|
||||||
e.currentTarget.style.boxShadow = "none";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Список агентов */}
|
|
||||||
<div className="flex-1 overflow-y-auto px-2 py-2">
|
|
||||||
{isLoading && agents.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center py-12">
|
|
||||||
<FaSpinner className="animate-spin mb-3" style={{ color: "var(--accent)", fontSize: "20px" }} />
|
|
||||||
<p className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Загрузка агентов...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<div className="text-xs mb-2" style={{ color: "var(--error-text)" }}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={fetchAgents}
|
|
||||||
className="text-xs hover:underline"
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
Попробовать снова
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : filteredAgents.length === 0 ? (
|
|
||||||
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>
|
|
||||||
<FaMicrochip className="mx-auto mb-2 opacity-50" size={16} />
|
|
||||||
<p className="text-xs">
|
|
||||||
{searchQuery ? "Ничего не найдено" : "Нет агентов"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{filteredAgents.map((agent) => (
|
|
||||||
<div
|
|
||||||
key={agent.name}
|
|
||||||
className="rounded-lg border p-3 transition-all hover:shadow-md"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{agent.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{agent.services.map((service) => (
|
|
||||||
<div
|
|
||||||
key={service.name}
|
|
||||||
className="flex items-center justify-between text-xs"
|
|
||||||
>
|
|
||||||
<span style={{ color: "var(--text-secondary)" }}>{service.name}</span>
|
|
||||||
<span
|
|
||||||
className="px-1.5 py-0.5 rounded text-[10px] font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
service.status === "running"
|
|
||||||
? "var(--success-bg)"
|
|
||||||
: service.status === "error"
|
|
||||||
? "var(--error-bg)"
|
|
||||||
: "var(--bg-secondary)",
|
|
||||||
color:
|
|
||||||
service.status === "running"
|
|
||||||
? "var(--success-text)"
|
|
||||||
: service.status === "error"
|
|
||||||
? "var(--error-text)"
|
|
||||||
: "var(--text-muted)",
|
|
||||||
border: `1px solid ${
|
|
||||||
service.status === "running"
|
|
||||||
? "var(--success-border)"
|
|
||||||
: service.status === "error"
|
|
||||||
? "var(--error-border)"
|
|
||||||
: "var(--border)"
|
|
||||||
}`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{service.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer с кнопками */}
|
|
||||||
<div
|
|
||||||
className="p-2 border-t flex gap-2"
|
|
||||||
style={{ borderColor: "var(--border)", backgroundColor: "var(--card-bg)" }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTokenModal(true)}
|
|
||||||
className="flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "var(--accent-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaCopy size={10} />
|
|
||||||
Токен
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Modal токена */}
|
|
||||||
{showTokenModal && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
|
|
||||||
style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
|
|
||||||
onClick={() => setShowTokenModal(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-md rounded-xl shadow-2xl border"
|
|
||||||
style={{ backgroundColor: "var(--card-bg)", borderColor: "var(--border)" }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="flex items-center justify-between px-4 py-3 border-b"
|
|
||||||
style={{ borderColor: "var(--border)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FaCopy style={{ color: "var(--accent)" }} size={14} />
|
|
||||||
<h2 className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
|
|
||||||
Ваш токен доступа
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTokenModal(false)}
|
|
||||||
className="p-1 rounded transition-colors"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
<FaTimes size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium mb-2" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Токен
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 rounded-lg p-3 border"
|
|
||||||
style={{ backgroundColor: "var(--bg-secondary)", borderColor: "var(--border)" }}
|
|
||||||
>
|
|
||||||
<code
|
|
||||||
className="flex-1 text-xs font-mono break-all"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
{token || "Токен не найден"}
|
|
||||||
</code>
|
|
||||||
{token && (
|
|
||||||
<button
|
|
||||||
onClick={handleCopyToken}
|
|
||||||
className="p-1.5 rounded transition-colors"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
{copied ? (
|
|
||||||
<FaCheck size={12} style={{ color: "var(--success-text)" }} />
|
|
||||||
) : (
|
|
||||||
<FaCopy size={12} />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTokenModal(false)}
|
|
||||||
className="w-full py-2 rounded-lg text-xs font-medium transition-colors"
|
|
||||||
style={{ backgroundColor: "var(--accent)", color: "var(--accent-text)" }}
|
|
||||||
>
|
|
||||||
Закрыть
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
|
||||||
import type { AgentInfo } from "@/modules/agent/types/agent.types";
|
|
||||||
|
|
||||||
interface AgentState {
|
|
||||||
agents: AgentInfo[];
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
fetchAgents: () => Promise<void>;
|
|
||||||
removeAgent: (name: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAgentStore = create<AgentState>()((set, get) => ({
|
|
||||||
agents: [],
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
|
|
||||||
fetchAgents: async () => {
|
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
try {
|
|
||||||
const agents = await agentApiService.getAgents();
|
|
||||||
set({ agents, isLoading: false });
|
|
||||||
} catch (error) {
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : "Failed to fetch agents",
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
removeAgent: (name: string) => {
|
|
||||||
set({ agents: get().agents.filter((a) => a.name !== name) });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
import { useAuthStore } from "@/store/auth/auth.store";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
|
|
||||||
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
export const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { isAuthenticated } = useAuthStore();
|
||||||
|
|
||||||
// if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
// return <Navigate to="/auth" replace />;
|
return <Navigate to="/auth" replace />;
|
||||||
// }
|
}
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,108 +2,10 @@ import { Suspense } from "react";
|
|||||||
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
||||||
import { HomePage } from "@/pages/home.page";
|
import { HomePage } from "@/pages/home.page";
|
||||||
import { ThemesPage } from "@/pages/themes.page";
|
import { ThemesPage } from "@/pages/themes.page";
|
||||||
import { TestPage } from "@/pages/test.page";
|
|
||||||
import { Test2Page, type GraphData } from "@/pages/test2.page";
|
|
||||||
import { AuthPage } from "@/pages/auth.page";
|
import { AuthPage } from "@/pages/auth.page";
|
||||||
import { RegisterPage } from "@/pages/register.page";
|
import { RegisterPage } from "@/pages/register.page";
|
||||||
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
|
||||||
import { AddAgentsPage } from "@/pages/add-agents.page";
|
import { AddAgentsPage } from "@/pages/add-agents.page";
|
||||||
import { IDEPage } from "@/pages/ide.page";
|
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
||||||
import { AdminPage } from "@/pages/admin.page";
|
|
||||||
import { RegistrationTokenPage } from "@/pages/registration.page";
|
|
||||||
import { LogsPage } from "@/pages/logs.page";
|
|
||||||
|
|
||||||
export const mockGraphData: GraphData = {
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: "api-gateway",
|
|
||||||
name: "API Gateway",
|
|
||||||
type: "service",
|
|
||||||
val: 12,
|
|
||||||
description: "Входная точка API",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "auth-service",
|
|
||||||
name: "Auth Service",
|
|
||||||
type: "service",
|
|
||||||
val: 12,
|
|
||||||
description: "Аутентификация",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "db-service",
|
|
||||||
name: "Database",
|
|
||||||
type: "service",
|
|
||||||
val: 12,
|
|
||||||
description: "Хранилище данных",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "redis-service",
|
|
||||||
name: "Redis",
|
|
||||||
type: "service",
|
|
||||||
val: 12,
|
|
||||||
description: "Кэширование",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "queue-service",
|
|
||||||
name: "Message Queue",
|
|
||||||
type: "service",
|
|
||||||
val: 12,
|
|
||||||
description: "Очередь сообщений",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "user-agent",
|
|
||||||
name: "User Agent",
|
|
||||||
type: "agent",
|
|
||||||
val: 8,
|
|
||||||
description: "Обработка пользователей",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "payment-agent",
|
|
||||||
name: "Payment Agent",
|
|
||||||
type: "agent",
|
|
||||||
val: 8,
|
|
||||||
description: "Платежи",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "notification-agent",
|
|
||||||
name: "Notification Agent",
|
|
||||||
type: "agent",
|
|
||||||
val: 8,
|
|
||||||
description: "Уведомления",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "analytics-agent",
|
|
||||||
name: "Analytics Agent",
|
|
||||||
type: "agent",
|
|
||||||
val: 8,
|
|
||||||
description: "Аналитика",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "report-agent",
|
|
||||||
name: "Report Agent",
|
|
||||||
type: "agent",
|
|
||||||
val: 8,
|
|
||||||
description: "Отчеты",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
links: [
|
|
||||||
{ source: "user-agent", target: "api-gateway", type: "uses" },
|
|
||||||
{ source: "user-agent", target: "auth-service", type: "uses" },
|
|
||||||
{ source: "user-agent", target: "db-service", type: "uses" },
|
|
||||||
{ source: "payment-agent", target: "api-gateway", type: "uses" },
|
|
||||||
{ source: "payment-agent", target: "auth-service", type: "uses" },
|
|
||||||
{ source: "payment-agent", target: "queue-service", type: "uses" },
|
|
||||||
{ source: "notification-agent", target: "redis-service", type: "uses" },
|
|
||||||
{ source: "notification-agent", target: "queue-service", type: "uses" },
|
|
||||||
{ source: "analytics-agent", target: "db-service", type: "uses" },
|
|
||||||
{ source: "report-agent", target: "db-service", type: "uses" },
|
|
||||||
{ source: "report-agent", target: "redis-service", type: "uses" },
|
|
||||||
{ source: "api-gateway", target: "auth-service", type: "depends_on" },
|
|
||||||
{ source: "auth-service", target: "db-service", type: "depends_on" },
|
|
||||||
{ source: "api-gateway", target: "queue-service", type: "depends_on" },
|
|
||||||
{ source: "queue-service", target: "redis-service", type: "depends_on" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const Routing = () => {
|
export const Routing = () => {
|
||||||
return (
|
return (
|
||||||
@@ -115,24 +17,15 @@ export const Routing = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<ReactRoutes>
|
<ReactRoutes>
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
|
||||||
|
|
||||||
<Route element={<DefaultLayout />}>
|
<Route element={<DefaultLayout />}>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
|
<Route path="/auth" element={<AuthPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
<Route path="/themes" element={<ThemesPage />} />
|
<Route path="/themes" element={<ThemesPage />} />
|
||||||
<Route path="/add-agents" element={<AddAgentsPage />} />
|
<Route path="/add-agents" element={<AddAgentsPage />} />
|
||||||
<Route path="/registration" element={<RegistrationTokenPage />} />
|
|
||||||
<Route path="/logs" element={<LogsPage />} />
|
|
||||||
<Route path="/admin" element={<AdminPage />} />
|
|
||||||
<Route path="/IDE" element={<IDEPage />} />
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/test" element={<TestPage />} />
|
|
||||||
|
|
||||||
<Route path="/test2" element={<Test2Page data={mockGraphData} />} />
|
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Route>
|
||||||
</ReactRoutes>
|
</ReactRoutes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
import { apiClient } from "@/shared/api/axios.instance";
|
|
||||||
import type {
|
|
||||||
AgentInfo,
|
|
||||||
TokenCreate,
|
|
||||||
TokenUser,
|
|
||||||
LogEntry,
|
|
||||||
LogFilters,
|
|
||||||
InsertLogRequest,
|
|
||||||
InsertLogsRequest,
|
|
||||||
TokenUpdate,
|
|
||||||
TokenUpdatePermissions,
|
|
||||||
TokenPasswordReset,
|
|
||||||
RegistrationRequest,
|
|
||||||
DeployAgentsRequest,
|
|
||||||
DeployResponse,
|
|
||||||
} from "../types/agent.types";
|
|
||||||
|
|
||||||
class AgentApiService {
|
|
||||||
private readonly basePath = "/agents";
|
|
||||||
private readonly authBasePath = "/auth";
|
|
||||||
private readonly logsBasePath = "/logs";
|
|
||||||
|
|
||||||
async getAgents(): Promise<AgentInfo[]> {
|
|
||||||
const response = await apiClient.get<AgentInfo[]>(this.basePath);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUsers(): Promise<TokenUser[]> {
|
|
||||||
const response = await apiClient.get<TokenUser[]>(
|
|
||||||
`${this.authBasePath}/tokens`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createUser(data: TokenCreate): Promise<void> {
|
|
||||||
await apiClient.post(`${this.authBasePath}/token`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteUser(login: string): Promise<void> {
|
|
||||||
await apiClient.delete(`${this.authBasePath}/tokens/${login}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteMyAccount(): Promise<void> {
|
|
||||||
await apiClient.delete(`${this.authBasePath}/token`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async searchLogs(filters?: LogFilters): Promise<LogEntry[]> {
|
|
||||||
const response = await apiClient.get<LogEntry[]>(this.logsBasePath, {
|
|
||||||
params: {
|
|
||||||
level: filters?.level,
|
|
||||||
service: filters?.service,
|
|
||||||
agent: filters?.agent,
|
|
||||||
date_from: filters?.date_from,
|
|
||||||
date_to: filters?.date_to,
|
|
||||||
limit: filters?.limit ?? 100,
|
|
||||||
offset: filters?.offset ?? 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async insertLog(entry: InsertLogRequest): Promise<void> {
|
|
||||||
await apiClient.post(this.logsBasePath, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
async insertLogsBatch(data: InsertLogsRequest): Promise<void> {
|
|
||||||
await apiClient.post(`${this.logsBasePath}/batch`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDistinctAgents(): Promise<string[]> {
|
|
||||||
const response = await apiClient.get<string[]>(
|
|
||||||
`${this.logsBasePath}/agents`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDistinctLevels(): Promise<string[]> {
|
|
||||||
const response = await apiClient.get<string[]>(
|
|
||||||
`${this.logsBasePath}/levels`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDistinctServices(): Promise<string[]> {
|
|
||||||
const response = await apiClient.get<string[]>(
|
|
||||||
`${this.logsBasePath}/services`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User management methods
|
|
||||||
async getUserByLogin(login: string): Promise<TokenUser> {
|
|
||||||
const response = await apiClient.get<TokenUser>(
|
|
||||||
`${this.authBasePath}/users/${login}`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getInactiveUsers(): Promise<TokenUser[]> {
|
|
||||||
const response = await apiClient.get<TokenUser[]>(
|
|
||||||
`${this.authBasePath}/users/inactive`,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUser(login: string, data: TokenUpdate): Promise<void> {
|
|
||||||
await apiClient.put(`${this.authBasePath}/users/${login}`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUserPermissions(
|
|
||||||
login: string,
|
|
||||||
data: TokenUpdatePermissions,
|
|
||||||
): Promise<void> {
|
|
||||||
await apiClient.put(
|
|
||||||
`${this.authBasePath}/users/${login}/permissions`,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async resetUserPassword(
|
|
||||||
login: string,
|
|
||||||
data: TokenPasswordReset,
|
|
||||||
): Promise<void> {
|
|
||||||
await apiClient.put(`${this.authBasePath}/users/${login}/password`, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async activateUser(login: string): Promise<void> {
|
|
||||||
await apiClient.post(`${this.authBasePath}/users/${login}/activate`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deactivateUser(login: string): Promise<void> {
|
|
||||||
await apiClient.post(`${this.authBasePath}/users/${login}/deactivate`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createRegistrationToken(
|
|
||||||
data: RegistrationRequest,
|
|
||||||
): Promise<Record<string, string>> {
|
|
||||||
const response = await apiClient.post<Record<string, string>>(
|
|
||||||
`${this.basePath}/register-token`,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async deployAgents(data: DeployAgentsRequest): Promise<DeployResponse> {
|
|
||||||
const response = await apiClient.post<DeployResponse>(
|
|
||||||
`${this.basePath}/deploy`,
|
|
||||||
data,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const agentApiService = new AgentApiService();
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
import { agentApiService } from "../api/agent.api.service";
|
|
||||||
import type { AgentInfo } from "../types/agent.types";
|
|
||||||
|
|
||||||
interface UseAgentsResult {
|
|
||||||
agents: AgentInfo[];
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
refetch: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAgents(): UseAgentsResult {
|
|
||||||
const [agents, setAgents] = useState<AgentInfo[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchAgents = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const data = await agentApiService.getAgents();
|
|
||||||
setAgents(data);
|
|
||||||
} catch (err) {
|
|
||||||
const message = err instanceof Error ? err.message : "Failed to fetch agents";
|
|
||||||
setError(message);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAgents();
|
|
||||||
}, [fetchAgents]);
|
|
||||||
|
|
||||||
return { agents, isLoading, error, refetch: fetchAgents };
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
export { SSHAgentForm } from "./ui/SSHAgentForm";
|
|
||||||
export type { SSHAgentConfig, ExtraField } from "./ui/SSHAgentForm";
|
|
||||||
|
|
||||||
export { useAgents } from "./hooks/useAgents.hook";
|
|
||||||
|
|
||||||
export { agentApiService } from "./api/agent.api.service";
|
|
||||||
|
|
||||||
export type {
|
|
||||||
AgentInfo,
|
|
||||||
LoginRequest,
|
|
||||||
LoginResponse,
|
|
||||||
TokenCreate,
|
|
||||||
TokenUser,
|
|
||||||
LogEntry,
|
|
||||||
InsertLogRequest,
|
|
||||||
InsertLogsRequest,
|
|
||||||
LogFilters,
|
|
||||||
TokenUpdate,
|
|
||||||
TokenUpdatePermissions,
|
|
||||||
TokenPasswordReset,
|
|
||||||
RegistrationRequest,
|
|
||||||
DeployResult,
|
|
||||||
DeployAgentsRequest,
|
|
||||||
AgentDeployConfig,
|
|
||||||
DeployResponse,
|
|
||||||
} from "./types/agent.types";
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
export type LogLevel = "INFO" | "WARNING" | "ERROR" | "FATAL";
|
|
||||||
|
|
||||||
interface LogFilterState {
|
|
||||||
searchQuery: string;
|
|
||||||
startDate: Date | null;
|
|
||||||
endDate: Date | null;
|
|
||||||
selectedLogLevels: LogLevel[];
|
|
||||||
selectedService: string;
|
|
||||||
selectedAgent: string;
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
|
|
||||||
setSearchQuery: (query: string) => void;
|
|
||||||
setStartDate: (date: Date | null) => void;
|
|
||||||
setEndDate: (date: Date | null) => void;
|
|
||||||
toggleLogLevel: (level: LogLevel) => void;
|
|
||||||
setSelectedService: (service: string) => void;
|
|
||||||
setSelectedAgent: (agent: string) => void;
|
|
||||||
setLimit: (limit: number) => void;
|
|
||||||
setOffset: (offset: number) => void;
|
|
||||||
resetFilters: () => void;
|
|
||||||
getFilters: () => {
|
|
||||||
level?: string;
|
|
||||||
service?: string;
|
|
||||||
agent?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useLogFilterStore = create<LogFilterState>((set, get) => ({
|
|
||||||
searchQuery: "",
|
|
||||||
startDate: null,
|
|
||||||
endDate: null,
|
|
||||||
selectedLogLevels: ["INFO", "WARNING", "ERROR", "FATAL"],
|
|
||||||
selectedService: "",
|
|
||||||
selectedAgent: "",
|
|
||||||
limit: 100,
|
|
||||||
offset: 0,
|
|
||||||
|
|
||||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
|
||||||
setStartDate: (date) => set({ startDate: date }),
|
|
||||||
setEndDate: (date) => set({ endDate: date }),
|
|
||||||
toggleLogLevel: (level) => {
|
|
||||||
const { selectedLogLevels } = get();
|
|
||||||
if (selectedLogLevels.includes(level)) {
|
|
||||||
set({ selectedLogLevels: selectedLogLevels.filter((l) => l !== level) });
|
|
||||||
} else {
|
|
||||||
set({ selectedLogLevels: [...selectedLogLevels, level] });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
setSelectedService: (service) => set({ selectedService: service }),
|
|
||||||
setSelectedAgent: (agent) => set({ selectedAgent: agent }),
|
|
||||||
setLimit: (limit) => set({ limit }),
|
|
||||||
setOffset: (offset) => set({ offset }),
|
|
||||||
|
|
||||||
resetFilters: () => {
|
|
||||||
set({
|
|
||||||
searchQuery: "",
|
|
||||||
startDate: null,
|
|
||||||
endDate: null,
|
|
||||||
selectedLogLevels: ["INFO", "WARNING", "ERROR", "FATAL"],
|
|
||||||
selectedService: "",
|
|
||||||
selectedAgent: "",
|
|
||||||
limit: 100,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getFilters: () => {
|
|
||||||
const { selectedLogLevels, selectedService, selectedAgent, startDate, endDate, limit, offset } = get();
|
|
||||||
return {
|
|
||||||
level: selectedLogLevels.length > 0 ? selectedLogLevels.join(",") : undefined,
|
|
||||||
service: selectedService || undefined,
|
|
||||||
agent: selectedAgent || undefined,
|
|
||||||
date_from: startDate ? startDate.toISOString() : undefined,
|
|
||||||
date_to: endDate ? endDate.toISOString() : undefined,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
export interface AgentService {
|
|
||||||
name: string;
|
|
||||||
status: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentInfo {
|
|
||||||
name: string;
|
|
||||||
services: AgentService[];
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginRequest {
|
|
||||||
login: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
last_name: string;
|
|
||||||
login: string;
|
|
||||||
name: string;
|
|
||||||
permission_admin: boolean;
|
|
||||||
permission_manage_agent: boolean;
|
|
||||||
permission_view: boolean;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenCreate {
|
|
||||||
login: string;
|
|
||||||
name: string;
|
|
||||||
last_name: string;
|
|
||||||
password: string;
|
|
||||||
permission_admin?: boolean;
|
|
||||||
permission_manage_agent?: boolean;
|
|
||||||
permission_view?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenUser {
|
|
||||||
id: number;
|
|
||||||
login: string;
|
|
||||||
name: string;
|
|
||||||
last_name: string;
|
|
||||||
permission_admin: boolean;
|
|
||||||
permission_manage_agent: boolean;
|
|
||||||
permission_view: boolean;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogEntry {
|
|
||||||
agent: string;
|
|
||||||
level: string;
|
|
||||||
message: string;
|
|
||||||
service: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InsertLogRequest {
|
|
||||||
agent: string;
|
|
||||||
level: string;
|
|
||||||
message: string;
|
|
||||||
service: string;
|
|
||||||
timestamp?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface InsertLogsRequest {
|
|
||||||
logs: InsertLogRequest[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LogFilters {
|
|
||||||
level?: string;
|
|
||||||
service?: string;
|
|
||||||
agent?: string;
|
|
||||||
date_from?: string;
|
|
||||||
date_to?: string;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenUpdate {
|
|
||||||
name?: string;
|
|
||||||
last_name?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenUpdatePermissions {
|
|
||||||
is_active?: boolean;
|
|
||||||
permission_admin?: boolean;
|
|
||||||
permission_manage_agent?: boolean;
|
|
||||||
permission_view?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TokenPasswordReset {
|
|
||||||
new_password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegistrationRequest {
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeployResult {
|
|
||||||
agent_label: string;
|
|
||||||
error?: string;
|
|
||||||
ip: string;
|
|
||||||
success: boolean;
|
|
||||||
token?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeployAgentsRequest {
|
|
||||||
servers: AgentDeployConfig[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AgentDeployConfig {
|
|
||||||
agentLabel: string;
|
|
||||||
authMethod: "key" | "password";
|
|
||||||
deployType: "docker" | "binary";
|
|
||||||
ip: string;
|
|
||||||
password?: string;
|
|
||||||
port?: number;
|
|
||||||
sshKey?: string;
|
|
||||||
user: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeployResponse {
|
|
||||||
message?: string;
|
|
||||||
results: DeployResult[];
|
|
||||||
}
|
|
||||||
@@ -1,399 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import {
|
|
||||||
FiSearch,
|
|
||||||
FiX,
|
|
||||||
FiFilter,
|
|
||||||
FiCalendar,
|
|
||||||
FiTag,
|
|
||||||
FiCheck,
|
|
||||||
} from "react-icons/fi";
|
|
||||||
import { useLogFilterStore, type LogLevel } from "../store/logFilter.store";
|
|
||||||
|
|
||||||
const logLevelColors: Record<LogLevel, { bg: string; text: string; border: string }> = {
|
|
||||||
INFO: { bg: "var(--info-bg)", text: "var(--info-text)", border: "var(--info-border)" },
|
|
||||||
WARNING: { bg: "var(--warning-bg)", text: "var(--warning-text)", border: "var(--warning-border)" },
|
|
||||||
ERROR: { bg: "var(--error-bg)", text: "var(--error-text)", border: "var(--error-border)" },
|
|
||||||
FATAL: { bg: "var(--fatal-bg)", text: "var(--fatal-text)", border: "var(--fatal-border)" },
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LogFiltersProps {
|
|
||||||
onApply: () => void;
|
|
||||||
availableServices: string[];
|
|
||||||
availableAgents: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LogFilters: React.FC<LogFiltersProps> = ({ onApply, availableServices, availableAgents }) => {
|
|
||||||
const {
|
|
||||||
searchQuery,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
selectedLogLevels,
|
|
||||||
selectedService,
|
|
||||||
selectedAgent,
|
|
||||||
setSearchQuery,
|
|
||||||
setStartDate,
|
|
||||||
setEndDate,
|
|
||||||
toggleLogLevel,
|
|
||||||
setSelectedService,
|
|
||||||
setSelectedAgent,
|
|
||||||
resetFilters,
|
|
||||||
} = useLogFilterStore();
|
|
||||||
|
|
||||||
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
|
||||||
const [localStartDate, setLocalStartDate] = useState<Date | null>(startDate);
|
|
||||||
const [localEndDate, setLocalEndDate] = useState<Date | null>(endDate);
|
|
||||||
const [localService, setLocalService] = useState(selectedService);
|
|
||||||
const [localAgent, setLocalAgent] = useState(selectedAgent);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalSearchQuery(searchQuery);
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalStartDate(startDate);
|
|
||||||
}, [startDate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalEndDate(endDate);
|
|
||||||
}, [endDate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalService(selectedService);
|
|
||||||
}, [selectedService]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalAgent(selectedAgent);
|
|
||||||
}, [selectedAgent]);
|
|
||||||
|
|
||||||
const handleApply = useCallback(() => {
|
|
||||||
setSearchQuery(localSearchQuery);
|
|
||||||
setStartDate(localStartDate);
|
|
||||||
setEndDate(localEndDate);
|
|
||||||
setSelectedService(localService);
|
|
||||||
setSelectedAgent(localAgent);
|
|
||||||
onApply();
|
|
||||||
}, [localSearchQuery, localStartDate, localEndDate, localService, localAgent, onApply]);
|
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
setLocalSearchQuery("");
|
|
||||||
setLocalStartDate(null);
|
|
||||||
setLocalEndDate(null);
|
|
||||||
setLocalService("");
|
|
||||||
setLocalAgent("");
|
|
||||||
resetFilters();
|
|
||||||
onApply();
|
|
||||||
}, [resetFilters, onApply]);
|
|
||||||
|
|
||||||
const getActiveFiltersCount = () => {
|
|
||||||
let count = 0;
|
|
||||||
if (searchQuery) count++;
|
|
||||||
if (startDate) count++;
|
|
||||||
if (endDate) count++;
|
|
||||||
if (selectedService) count++;
|
|
||||||
if (selectedAgent) count++;
|
|
||||||
if (selectedLogLevels.length < 4) count++;
|
|
||||||
return count;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (date: Date | null) => {
|
|
||||||
if (!date) return null;
|
|
||||||
return date.toLocaleDateString("ru-RU");
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeFiltersCount = getActiveFiltersCount();
|
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectStyle: React.CSSProperties = {
|
|
||||||
...inputStyle,
|
|
||||||
cursor: "pointer",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="rounded-xl border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="p-4">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FiFilter size={14} style={{ color: "var(--accent)" }} />
|
|
||||||
<h3 className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
|
|
||||||
Фильтры логов
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Активно: {activeFiltersCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters Grid */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative">
|
|
||||||
<FiSearch
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "10px",
|
|
||||||
top: "50%",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
fontSize: "14px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={localSearchQuery}
|
|
||||||
onChange={(e) => setLocalSearchQuery(e.target.value)}
|
|
||||||
placeholder="Поиск по сообщению..."
|
|
||||||
style={{ ...inputStyle, paddingLeft: "32px" }}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleApply()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Service Select */}
|
|
||||||
<select
|
|
||||||
value={localService}
|
|
||||||
onChange={(e) => setLocalService(e.target.value)}
|
|
||||||
style={selectStyle}
|
|
||||||
>
|
|
||||||
<option value="">Все сервисы</option>
|
|
||||||
{availableServices.map((service) => (
|
|
||||||
<option key={service} value={service}>
|
|
||||||
{service}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Agent Select */}
|
|
||||||
<select
|
|
||||||
value={localAgent}
|
|
||||||
onChange={(e) => setLocalAgent(e.target.value)}
|
|
||||||
style={selectStyle}
|
|
||||||
>
|
|
||||||
<option value="">Все агенты</option>
|
|
||||||
{availableAgents.map((agent) => (
|
|
||||||
<option key={agent} value={agent}>
|
|
||||||
{agent}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Date Range */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={localStartDate ? localStartDate.toISOString().split("T")[0] : ""}
|
|
||||||
onChange={(e) => setLocalStartDate(e.target.value ? new Date(e.target.value) : null)}
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="Дата от"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={localEndDate ? localEndDate.toISOString().split("T")[0] : ""}
|
|
||||||
onChange={(e) => setLocalEndDate(e.target.value ? new Date(e.target.value) : null)}
|
|
||||||
style={inputStyle}
|
|
||||||
placeholder="Дата до"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Log Levels */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<FiTag size={12} style={{ color: "var(--text-secondary)" }} />
|
|
||||||
<span className="text-xs font-medium" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Уровни логов
|
|
||||||
</span>
|
|
||||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
|
||||||
({selectedLogLevels.length}/4)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(["INFO", "WARNING", "ERROR", "FATAL"] as LogLevel[]).map((level) => {
|
|
||||||
const isSelected = selectedLogLevels.includes(level);
|
|
||||||
const colors = logLevelColors[level];
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={level}
|
|
||||||
onClick={() => toggleLogLevel(level)}
|
|
||||||
className="px-3 py-1.5 rounded-lg text-xs font-medium transition-all border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: isSelected ? colors.bg : "transparent",
|
|
||||||
color: isSelected ? colors.text : "var(--text-secondary)",
|
|
||||||
borderColor: isSelected ? colors.border : "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSelected && <FiCheck size={10} className="inline mr-1" />}
|
|
||||||
{level}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleApply}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--button-primary)",
|
|
||||||
color: "var(--button-primary-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiCheck size={14} />
|
|
||||||
Применить
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
className="px-4 py-2 rounded-lg transition-all text-sm font-medium border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiX size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Active Filters Display */}
|
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border)" }}>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<FiFilter size={10} style={{ color: "var(--accent)" }} />
|
|
||||||
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Активные фильтры:
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{searchQuery && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiSearch size={10} />
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>Поиск: {searchQuery}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setLocalSearchQuery("");
|
|
||||||
setSearchQuery("");
|
|
||||||
onApply();
|
|
||||||
}}
|
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
<FiX size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedService && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiTag size={10} />
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>Сервис: {selectedService}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setLocalService("");
|
|
||||||
setSelectedService("");
|
|
||||||
onApply();
|
|
||||||
}}
|
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
<FiX size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{selectedAgent && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiTag size={10} />
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>Агент: {selectedAgent}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setLocalAgent("");
|
|
||||||
setSelectedAgent("");
|
|
||||||
onApply();
|
|
||||||
}}
|
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
<FiX size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{startDate && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiCalendar size={10} />
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>С: {formatDate(startDate)}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setLocalStartDate(null);
|
|
||||||
setStartDate(null);
|
|
||||||
onApply();
|
|
||||||
}}
|
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
<FiX size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{endDate && (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiCalendar size={10} />
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>По: {formatDate(endDate)}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setLocalEndDate(null);
|
|
||||||
setEndDate(null);
|
|
||||||
onApply();
|
|
||||||
}}
|
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
<FiX size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
FiPlus,
|
FiPlus,
|
||||||
FiTrash2,
|
FiTrash2,
|
||||||
FiSettings,
|
FiSettings,
|
||||||
FiLink,
|
|
||||||
} from "react-icons/fi";
|
} from "react-icons/fi";
|
||||||
import { SiDocker } from "react-icons/si";
|
import { SiDocker } from "react-icons/si";
|
||||||
import { FiPackage, FiUploadCloud } from "react-icons/fi";
|
import { FiPackage, FiUploadCloud } from "react-icons/fi";
|
||||||
@@ -21,10 +20,8 @@ interface ExtraField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SSHAgentConfig {
|
export interface SSHAgentConfig {
|
||||||
agentLabel: string;
|
|
||||||
user: string;
|
user: string;
|
||||||
ip: string;
|
ip: string;
|
||||||
port: number;
|
|
||||||
authMethod: AuthMethod;
|
authMethod: AuthMethod;
|
||||||
sshKey?: string;
|
sshKey?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -192,31 +189,11 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "20px" }}>
|
<div style={{ display: "grid", gap: "20px" }}>
|
||||||
{/* Agent Label */}
|
{/* User и IP */}
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
||||||
<FiServer size={14} />
|
|
||||||
Метка агента *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.agentLabel}
|
|
||||||
onChange={(e) => handleChange("agentLabel", e.target.value)}
|
|
||||||
required
|
|
||||||
style={inputBaseStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="production-server-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User, IP и Port */}
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr 1fr",
|
gridTemplateColumns: "1fr 1fr",
|
||||||
gap: "16px",
|
gap: "16px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -261,31 +238,6 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
placeholder="192.168.1.1"
|
placeholder="192.168.1.1"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
|
||||||
>
|
|
||||||
<FiLink size={14} />
|
|
||||||
Порт *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={config.port}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChange("port", parseInt(e.target.value) || 22)
|
|
||||||
}
|
|
||||||
required
|
|
||||||
min={1}
|
|
||||||
max={65535}
|
|
||||||
style={inputBaseStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="22"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Метод аутентификации */}
|
{/* Метод аутентификации */}
|
||||||
@@ -505,7 +457,7 @@ export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
gridTemplateColumns: "1fr 1fr",
|
gridTemplateColumns: "1fr 1fr 1fr",
|
||||||
gap: "8px",
|
gap: "8px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -17,18 +17,12 @@ const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
const register = async (
|
const register = async (data: RegisterData): Promise<LoginResponse> => {
|
||||||
data: RegisterData,
|
const response = await apiClient.post<LoginResponse>("/auth/register", {
|
||||||
): Promise<Record<string, string>> => {
|
|
||||||
const response = await apiClient.post<Record<string, string>>("/auth/token", {
|
|
||||||
login: data.login,
|
login: data.login,
|
||||||
password: data.password,
|
password: data.password,
|
||||||
name: data.firstName,
|
name: data.firstName,
|
||||||
last_name: data.lastName,
|
last_name: data.lastName,
|
||||||
is_active: data.is_active,
|
|
||||||
permission_admin: data.permission_admin,
|
|
||||||
permission_manage_agent: data.permission_manage_agent,
|
|
||||||
permission_view: data.permission_view,
|
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
@@ -68,10 +62,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
register: async (data: RegisterData) => {
|
register: async (data: RegisterData) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await register(data);
|
const response = await register(data);
|
||||||
// После регистрации пользователь не авторизуется автоматически
|
const user = mapResponseToUser(response);
|
||||||
// Нужно войти через /auth/login
|
set({ user, token: response.token, isLoading: false });
|
||||||
set({ isLoading: false });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
error:
|
error:
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ export interface RegisterData {
|
|||||||
password: string;
|
password: string;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
is_active?: boolean;
|
|
||||||
permission_admin?: boolean;
|
|
||||||
permission_manage_agent?: boolean;
|
|
||||||
permission_view?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
|
|||||||
@@ -1,263 +0,0 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import { MdAdd, MdArrowBack } from "react-icons/md";
|
|
||||||
import { GoTrash } from "react-icons/go";
|
|
||||||
import {
|
|
||||||
useIDEStore,
|
|
||||||
initialFiles as defaultInitialFiles,
|
|
||||||
} from "./store/useIDEStore";
|
|
||||||
import type { FileNode } from "./types";
|
|
||||||
import {
|
|
||||||
FileExplorer,
|
|
||||||
TabBar,
|
|
||||||
CodeEditor,
|
|
||||||
TitleBar,
|
|
||||||
StatusBar,
|
|
||||||
} from "./components";
|
|
||||||
|
|
||||||
interface IDEProps {
|
|
||||||
initialFiles?: FileNode;
|
|
||||||
onBack?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const IDE: React.FC<IDEProps> = ({
|
|
||||||
initialFiles: externalFiles,
|
|
||||||
onBack,
|
|
||||||
}: IDEProps = {}) => {
|
|
||||||
const files = useIDEStore((state) => state.files);
|
|
||||||
const openFiles = useIDEStore((state) => state.openFiles);
|
|
||||||
const activeFile = useIDEStore((state) => state.activeFile);
|
|
||||||
const createNewProject = useIDEStore((state) => state.createNewProject);
|
|
||||||
const selectFile = useIDEStore((state) => state.selectFile);
|
|
||||||
const updateFileContent = useIDEStore((state) => state.updateFileContent);
|
|
||||||
const closeFile = useIDEStore((state) => state.closeFile);
|
|
||||||
const closeAllFiles = useIDEStore((state) => state.closeAllFiles);
|
|
||||||
const closeOtherFiles = useIDEStore((state) => state.closeOtherFiles);
|
|
||||||
const initialize = useIDEStore((state) => state.initialize);
|
|
||||||
const isInitialized = useIDEStore((state) => state.isInitialized);
|
|
||||||
|
|
||||||
// Инициализация файлов
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isInitialized) {
|
|
||||||
const filesToInit = externalFiles || defaultInitialFiles;
|
|
||||||
initialize(filesToInit);
|
|
||||||
}
|
|
||||||
}, [isInitialized, externalFiles, initialize]);
|
|
||||||
|
|
||||||
// Если проект не открыт
|
|
||||||
if (!files) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100vh",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
backgroundColor: "#1e1e1e",
|
|
||||||
fontFamily:
|
|
||||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TitleBar />
|
|
||||||
{onBack && (
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: "40px",
|
|
||||||
left: "12px",
|
|
||||||
background: "transparent",
|
|
||||||
border: "1px solid #3e3e42",
|
|
||||||
color: "#cccccc",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
padding: "6px 12px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
fontSize: "12px",
|
|
||||||
transition: "all 0.1s",
|
|
||||||
zIndex: 10,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#3e3e42";
|
|
||||||
e.currentTarget.style.color = "#fff";
|
|
||||||
e.currentTarget.style.borderColor = "#555";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
e.currentTarget.style.color = "#cccccc";
|
|
||||||
e.currentTarget.style.borderColor = "#3e3e42";
|
|
||||||
}}
|
|
||||||
title="Go back"
|
|
||||||
>
|
|
||||||
<MdArrowBack size={16} />
|
|
||||||
<span>Back</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ textAlign: "center" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: "24px",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: 0.3,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GoTrash size={72} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "22px",
|
|
||||||
marginBottom: "12px",
|
|
||||||
color: "#cccccc",
|
|
||||||
fontWeight: 300,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No project open
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "13px",
|
|
||||||
marginBottom: "32px",
|
|
||||||
color: "#858585",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create a new project to get started
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={createNewProject}
|
|
||||||
style={{
|
|
||||||
padding: "10px 24px",
|
|
||||||
backgroundColor: "#0e639c",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#fff",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: 500,
|
|
||||||
transition: "background-color 0.1s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#1177bb";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#0e639c";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdAdd size={14} /> New Project
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBar activeFile={null} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100vh",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: "#1e1e1e",
|
|
||||||
fontFamily:
|
|
||||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "30px",
|
|
||||||
backgroundColor: "#323233",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "0 8px",
|
|
||||||
borderBottom: "1px solid #1e1e1e",
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "#cccccc",
|
|
||||||
userSelect: "none",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{onBack && (
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: "#cccccc",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "4px",
|
|
||||||
padding: "4px 8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
fontSize: "11px",
|
|
||||||
transition: "all 0.1s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#3e3e42";
|
|
||||||
e.currentTarget.style.color = "#fff";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
e.currentTarget.style.color = "#cccccc";
|
|
||||||
}}
|
|
||||||
title="Go back"
|
|
||||||
>
|
|
||||||
<MdArrowBack size={14} />
|
|
||||||
<span>Back</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!onBack && <div />}
|
|
||||||
<span style={{ fontWeight: 400 }}>
|
|
||||||
{activeFile ? `${activeFile.name} - ` : ""}
|
|
||||||
{files.name}
|
|
||||||
</span>
|
|
||||||
<div style={{ width: 60 }} />
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", flex: 1, overflow: "hidden" }}>
|
|
||||||
<div style={{ width: "260px", flexShrink: 0 }}>
|
|
||||||
<FileExplorer
|
|
||||||
files={files}
|
|
||||||
onDeleteRoot={useIDEStore.getState().deleteRoot}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TabBar
|
|
||||||
openFiles={openFiles}
|
|
||||||
activeFile={activeFile}
|
|
||||||
onSelectFile={selectFile}
|
|
||||||
onCloseFile={closeFile}
|
|
||||||
onCloseAll={closeAllFiles}
|
|
||||||
onCloseOthers={closeOtherFiles}
|
|
||||||
/>
|
|
||||||
<CodeEditor
|
|
||||||
filePath={activeFile?.path || ""}
|
|
||||||
content={activeFile?.content || ""}
|
|
||||||
onChange={updateFileContent}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBar activeFile={activeFile} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IDE;
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import Editor from "@monaco-editor/react";
|
|
||||||
import { FiFolder } from "react-icons/fi";
|
|
||||||
import { getLanguage } from "../helpers/fileTree";
|
|
||||||
|
|
||||||
interface CodeEditorProps {
|
|
||||||
filePath: string;
|
|
||||||
content: string;
|
|
||||||
onChange: (content: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CodeEditor: React.FC<CodeEditorProps> = ({
|
|
||||||
filePath,
|
|
||||||
content,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
backgroundColor: "#1e1e1e",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
{filePath ? (
|
|
||||||
<Editor
|
|
||||||
height="100%"
|
|
||||||
language={getLanguage(filePath)}
|
|
||||||
value={content}
|
|
||||||
onChange={(value) => onChange(value || "")}
|
|
||||||
theme="vs-dark"
|
|
||||||
options={{
|
|
||||||
minimap: { enabled: false },
|
|
||||||
fontSize: 14,
|
|
||||||
fontFamily: "'Cascadia Code', 'Fira Code', monospace",
|
|
||||||
tabSize: 4,
|
|
||||||
wordWrap: "on",
|
|
||||||
lineNumbers: "on",
|
|
||||||
automaticLayout: true,
|
|
||||||
renderWhitespace: "selection",
|
|
||||||
smoothScrolling: true,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
height: "100%",
|
|
||||||
color: "#858585",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
marginBottom: "24px",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
opacity: 0.5,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiFolder size={64} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "18px",
|
|
||||||
marginBottom: "12px",
|
|
||||||
color: "#cccccc",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Welcome to Web VS Code
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "13px", marginBottom: "8px" }}>
|
|
||||||
Right-click on a folder to create files
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: "12px", color: "#0e639c" }}>
|
|
||||||
Or right-click anywhere in the explorer
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
import React, { useEffect } from "react";
|
|
||||||
import { FiFile, FiFolder, FiEdit3, FiTrash2 } from "react-icons/fi";
|
|
||||||
|
|
||||||
const MenuItem: React.FC<{
|
|
||||||
onClick: () => void;
|
|
||||||
danger?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}> = ({ onClick, danger, children }) => (
|
|
||||||
<div
|
|
||||||
onClick={onClick}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: danger ? "#f48771" : "#cccccc",
|
|
||||||
fontSize: "13px",
|
|
||||||
transition: "background-color 0.1s",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "10px",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
interface ContextMenuProps {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
onClose: () => void;
|
|
||||||
onNewFile: () => void;
|
|
||||||
onNewFolder: () => void;
|
|
||||||
onRename: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
hasNode: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
onClose,
|
|
||||||
onNewFile,
|
|
||||||
onNewFolder,
|
|
||||||
onRename,
|
|
||||||
onDelete,
|
|
||||||
hasNode,
|
|
||||||
}) => {
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClick = () => onClose();
|
|
||||||
document.addEventListener("click", handleClick);
|
|
||||||
return () => document.removeEventListener("click", handleClick);
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: y,
|
|
||||||
left: x,
|
|
||||||
backgroundColor: "#252526",
|
|
||||||
border: "1px solid #3e3e42",
|
|
||||||
borderRadius: "6px",
|
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
|
||||||
zIndex: 1000,
|
|
||||||
minWidth: "180px",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MenuItem onClick={onNewFile}>
|
|
||||||
<FiFile /> New File
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={onNewFolder}>
|
|
||||||
<FiFolder /> New Folder
|
|
||||||
</MenuItem>
|
|
||||||
{hasNode && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "1px",
|
|
||||||
backgroundColor: "#3e3e42",
|
|
||||||
margin: "4px 0",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MenuItem onClick={onRename}>
|
|
||||||
<FiEdit3 /> Rename
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem onClick={onDelete} danger>
|
|
||||||
<FiTrash2 /> Delete
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from "react";
|
|
||||||
import { FiSearch, FiFile, FiFolder, FiMinus } from "react-icons/fi";
|
|
||||||
import { GoKebabHorizontal } from "react-icons/go";
|
|
||||||
import { MdClose, MdAdd } from "react-icons/md";
|
|
||||||
import { FileTreeItem } from "./FileTreeItem";
|
|
||||||
import { ContextMenu } from "./ContextMenu";
|
|
||||||
import { InputDialog } from "./InputDialog";
|
|
||||||
import { filterTree, collectPathsToExpand } from "../helpers/fileTree";
|
|
||||||
import { useIDEStore } from "../store/useIDEStore";
|
|
||||||
import type { FileNode } from "../types";
|
|
||||||
|
|
||||||
interface FileExplorerProps {
|
|
||||||
files: FileNode;
|
|
||||||
onDeleteRoot: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FileExplorer: React.FC<FileExplorerProps> = ({
|
|
||||||
files,
|
|
||||||
onDeleteRoot,
|
|
||||||
}) => {
|
|
||||||
const store = useIDEStore();
|
|
||||||
const [showSearch, setShowSearch] = useState(false);
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
// Фокус на инпут при открытии поиска
|
|
||||||
useEffect(() => {
|
|
||||||
if (showSearch) {
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
}
|
|
||||||
}, [showSearch]);
|
|
||||||
|
|
||||||
const handleSearchBlur = useCallback(() => {
|
|
||||||
// Скрываем поиск при потере фокуса с небольшой задержкой,
|
|
||||||
// чтобы клики по кнопке очистки успели сработать
|
|
||||||
setTimeout(() => {
|
|
||||||
if (
|
|
||||||
searchInputRef.current &&
|
|
||||||
!searchInputRef.current.contains(document.activeElement)
|
|
||||||
) {
|
|
||||||
setShowSearch(false);
|
|
||||||
store.setSearchQuery("");
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}, [store]);
|
|
||||||
|
|
||||||
const handleEmptyContextMenu = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
store.setContextMenu({ x: e.clientX, y: e.clientY, node: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNodeContextMenu = (e: React.MouseEvent, node: FileNode) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
store.setContextMenu({ x: e.clientX, y: e.clientY, node });
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredFiles = store.searchQuery
|
|
||||||
? filterTree(files, store.searchQuery)
|
|
||||||
: files;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (store.searchQuery && files) {
|
|
||||||
const pathsToExpand = collectPathsToExpand(files, store.searchQuery);
|
|
||||||
if (pathsToExpand.size > 0) {
|
|
||||||
store.autoExpandPaths(pathsToExpand);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [store.searchQuery, files, store.autoExpandPaths]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
backgroundColor: "#252526",
|
|
||||||
}}
|
|
||||||
onContextMenu={handleEmptyContextMenu}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "0 8px",
|
|
||||||
height: "35px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
borderBottom: "1px solid #3e3e42",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: "#bbbbbb",
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: "11px",
|
|
||||||
letterSpacing: "0.8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
EXPLORER
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", gap: "2px", alignItems: "center" }}>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (!showSearch) {
|
|
||||||
setShowSearch(true);
|
|
||||||
} else {
|
|
||||||
setShowSearch(false);
|
|
||||||
store.setSearchQuery("");
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: showSearch ? "#cccccc" : "#858585",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "4px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
transition: "all 0.1s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
title="Search in files"
|
|
||||||
>
|
|
||||||
<FiSearch size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={store.collapseAllFolders}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: "#858585",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "4px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
transition: "all 0.1s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
||||||
e.currentTarget.style.color = "#cccccc";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
e.currentTarget.style.color = "#858585";
|
|
||||||
}}
|
|
||||||
title="Collapse All"
|
|
||||||
>
|
|
||||||
<FiMinus size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={store.expandAllFolders}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: "#858585",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "4px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
transition: "all 0.1s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
||||||
e.currentTarget.style.color = "#cccccc";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
e.currentTarget.style.color = "#858585";
|
|
||||||
}}
|
|
||||||
title="Expand All"
|
|
||||||
>
|
|
||||||
<GoKebabHorizontal size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "6px 12px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
borderBottom: "1px solid #3e3e42",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiFolder size={14} color="#858585" />
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: "#cccccc",
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: "11px",
|
|
||||||
letterSpacing: "0.3px",
|
|
||||||
flex: 1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{files.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showSearch && (
|
|
||||||
<div style={{ padding: "6px 8px", borderBottom: "1px solid #3e3e42" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: "#3c3c3c",
|
|
||||||
border: store.searchQuery
|
|
||||||
? "1px solid #007acc"
|
|
||||||
: "1px solid transparent",
|
|
||||||
borderRadius: "4px",
|
|
||||||
padding: "0 6px",
|
|
||||||
transition: "border-color 0.1s",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiSearch size={13} color="#858585" />
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
value={store.searchQuery}
|
|
||||||
onChange={(e) => store.setSearchQuery(e.target.value)}
|
|
||||||
onBlur={handleSearchBlur}
|
|
||||||
placeholder="Search..."
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
padding: "5px 6px",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: "#cccccc",
|
|
||||||
fontSize: "12px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{store.searchQuery && (
|
|
||||||
<button
|
|
||||||
onClick={() => store.setSearchQuery("")}
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
color: "#858585",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "2px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdClose size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: "auto" }}>
|
|
||||||
{filteredFiles ? (
|
|
||||||
<FileTreeItem
|
|
||||||
node={filteredFiles}
|
|
||||||
level={0}
|
|
||||||
onFileSelect={store.selectFile}
|
|
||||||
selectedFile={store.activeFile?.path || null}
|
|
||||||
onContextMenu={handleNodeContextMenu}
|
|
||||||
expandedFolders={store.expandedFolders}
|
|
||||||
onToggleFolder={store.toggleFolder}
|
|
||||||
onDelete={store.handleDeleteNode}
|
|
||||||
isRoot
|
|
||||||
searchQuery={store.searchQuery}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "16px",
|
|
||||||
color: "#858585",
|
|
||||||
fontSize: "13px",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No results found
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{store.contextMenu && (
|
|
||||||
<ContextMenu
|
|
||||||
x={store.contextMenu.x}
|
|
||||||
y={store.contextMenu.y}
|
|
||||||
onClose={() => store.setContextMenu(null)}
|
|
||||||
onNewFile={() => {
|
|
||||||
store.setDialog({
|
|
||||||
type: "newFile",
|
|
||||||
node: store.contextMenu?.node || null,
|
|
||||||
});
|
|
||||||
store.setContextMenu(null);
|
|
||||||
}}
|
|
||||||
onNewFolder={() => {
|
|
||||||
store.setDialog({
|
|
||||||
type: "newFolder",
|
|
||||||
node: store.contextMenu?.node || null,
|
|
||||||
});
|
|
||||||
store.setContextMenu(null);
|
|
||||||
}}
|
|
||||||
onRename={() => {
|
|
||||||
store.setDialog({
|
|
||||||
type: "rename",
|
|
||||||
node: store.contextMenu?.node || null,
|
|
||||||
});
|
|
||||||
store.setContextMenu(null);
|
|
||||||
}}
|
|
||||||
onDelete={() => {
|
|
||||||
if (store.contextMenu?.node) {
|
|
||||||
store.handleDeleteNode(store.contextMenu.node);
|
|
||||||
}
|
|
||||||
store.setContextMenu(null);
|
|
||||||
}}
|
|
||||||
hasNode={!!store.contextMenu.node}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{store.dialog && (
|
|
||||||
<InputDialog
|
|
||||||
title={
|
|
||||||
store.dialog.type === "newFile"
|
|
||||||
? "New File"
|
|
||||||
: store.dialog.type === "newFolder"
|
|
||||||
? "New Folder"
|
|
||||||
: "Rename"
|
|
||||||
}
|
|
||||||
initialValue={
|
|
||||||
store.dialog.type === "rename" && store.dialog.node
|
|
||||||
? store.dialog.node.name
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
onConfirm={store.handleDialogConfirm}
|
|
||||||
onCancel={() => store.setDialog(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { FiChevronRight, FiChevronDown, FiTrash2 } from "react-icons/fi";
|
|
||||||
import { GoFile } from "react-icons/go";
|
|
||||||
import type { FileNode } from "../types";
|
|
||||||
|
|
||||||
interface FileTreeItemProps {
|
|
||||||
node: FileNode;
|
|
||||||
level: number;
|
|
||||||
onFileSelect: (node: FileNode) => void;
|
|
||||||
selectedFile: string | null;
|
|
||||||
onContextMenu: (e: React.MouseEvent, node: FileNode) => void;
|
|
||||||
expandedFolders: Set<string>;
|
|
||||||
onToggleFolder: (path: string) => void;
|
|
||||||
onDelete: (node: FileNode) => void;
|
|
||||||
isRoot?: boolean;
|
|
||||||
searchQuery?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FileTreeItem: React.FC<FileTreeItemProps> = ({
|
|
||||||
node,
|
|
||||||
level,
|
|
||||||
onFileSelect,
|
|
||||||
selectedFile,
|
|
||||||
onContextMenu,
|
|
||||||
expandedFolders,
|
|
||||||
onToggleFolder,
|
|
||||||
onDelete,
|
|
||||||
isRoot,
|
|
||||||
searchQuery,
|
|
||||||
}) => {
|
|
||||||
const isFolder = node.type === "folder";
|
|
||||||
const isSelected = selectedFile === node.path && !isFolder;
|
|
||||||
const isExpanded = expandedFolders.has(node.path || node.name);
|
|
||||||
const [hovered, setHovered] = useState(false);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
if (isFolder) {
|
|
||||||
onToggleFolder(node.path || node.name);
|
|
||||||
} else {
|
|
||||||
onFileSelect(node);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDelete(node);
|
|
||||||
};
|
|
||||||
|
|
||||||
const highlightText = (text: string, query: string) => {
|
|
||||||
if (!query) return text;
|
|
||||||
const idx = text.toLowerCase().indexOf(query.toLowerCase());
|
|
||||||
if (idx === -1) return text;
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{text.slice(0, idx)}
|
|
||||||
<span style={{ backgroundColor: "#613214", color: "#f9f9a4" }}>
|
|
||||||
{text.slice(idx, idx + query.length)}
|
|
||||||
</span>
|
|
||||||
{text.slice(idx + query.length)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
onClick={handleClick}
|
|
||||||
onContextMenu={(e) => onContextMenu(e, node)}
|
|
||||||
onMouseEnter={() => setHovered(true)}
|
|
||||||
onMouseLeave={() => setHovered(false)}
|
|
||||||
style={{
|
|
||||||
paddingLeft: isRoot ? "8px" : `${level * 16 + 8}px`,
|
|
||||||
paddingTop: "4px",
|
|
||||||
paddingBottom: "4px",
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
backgroundColor: isSelected ? "#094771" : "transparent",
|
|
||||||
color: isSelected ? "#fff" : "#cccccc",
|
|
||||||
fontSize: "13px",
|
|
||||||
transition: "background-color 0.1s",
|
|
||||||
userSelect: "none",
|
|
||||||
minHeight: "28px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "14px",
|
|
||||||
width: "16px",
|
|
||||||
textAlign: "center",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isFolder ? (
|
|
||||||
isExpanded ? (
|
|
||||||
<FiChevronDown />
|
|
||||||
) : (
|
|
||||||
<FiChevronRight />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<GoFile />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{searchQuery ? highlightText(node.name, searchQuery) : node.name}
|
|
||||||
</span>
|
|
||||||
{hovered && !isRoot && (
|
|
||||||
<button
|
|
||||||
onClick={handleDelete}
|
|
||||||
title={`Delete ${node.name}`}
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
color: "#858585",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "2px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: "3px",
|
|
||||||
flexShrink: 0,
|
|
||||||
width: "20px",
|
|
||||||
height: "20px",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.color = "#f48771";
|
|
||||||
e.currentTarget.style.backgroundColor = "#3e3e42";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.color = "#858585";
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiTrash2 size={13} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{isFolder && isExpanded && node.children && (
|
|
||||||
<div>
|
|
||||||
{node.children.map((child, idx) => (
|
|
||||||
<FileTreeItem
|
|
||||||
key={idx}
|
|
||||||
node={child}
|
|
||||||
level={level + 1}
|
|
||||||
onFileSelect={onFileSelect}
|
|
||||||
selectedFile={selectedFile}
|
|
||||||
onContextMenu={onContextMenu}
|
|
||||||
expandedFolders={expandedFolders}
|
|
||||||
onToggleFolder={onToggleFolder}
|
|
||||||
onDelete={onDelete}
|
|
||||||
searchQuery={searchQuery}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
|
||||||
|
|
||||||
interface InputDialogProps {
|
|
||||||
title: string;
|
|
||||||
initialValue?: string;
|
|
||||||
onConfirm: (value: string) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InputDialog: React.FC<InputDialogProps> = ({
|
|
||||||
title,
|
|
||||||
initialValue = "",
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
}) => {
|
|
||||||
const [value, setValue] = useState(initialValue);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
inputRef.current?.select();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0,0,0,0.6)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: 2000,
|
|
||||||
}}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: "#2d2d30",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "24px",
|
|
||||||
minWidth: "320px",
|
|
||||||
border: "1px solid #3e3e42",
|
|
||||||
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
margin: "0 0 8px 0",
|
|
||||||
color: "#fff",
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<p style={{ margin: "0 0 16px 0", color: "#858585", fontSize: "12px" }}>
|
|
||||||
Enter a new name
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setValue(e.target.value)}
|
|
||||||
onKeyDown={(e) =>
|
|
||||||
e.key === "Enter" && value.trim() && onConfirm(value.trim())
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "10px",
|
|
||||||
backgroundColor: "#3c3c3c",
|
|
||||||
border: "1px solid #3e3e42",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "#ccc",
|
|
||||||
fontSize: "14px",
|
|
||||||
marginBottom: "20px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={onCancel}
|
|
||||||
style={{
|
|
||||||
padding: "6px 16px",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
border: "1px solid #0e639c",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#0e639c",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => value.trim() && onConfirm(value.trim())}
|
|
||||||
style={{
|
|
||||||
padding: "6px 16px",
|
|
||||||
backgroundColor: "#0e639c",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#fff",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { FiGitBranch, FiCheckCircle, FiAlertCircle } from "react-icons/fi";
|
|
||||||
import type { FileNode } from "../types";
|
|
||||||
|
|
||||||
interface StatusBarProps {
|
|
||||||
activeFile: FileNode | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StatusBar: React.FC<StatusBarProps> = ({ activeFile }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "22px",
|
|
||||||
backgroundColor: "#007acc",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "0 12px",
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "#ffffff",
|
|
||||||
userSelect: "none",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: "4px" }}>
|
|
||||||
<FiGitBranch size={12} /> main
|
|
||||||
</span>
|
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
||||||
<FiCheckCircle size={12} /> 0 <FiAlertCircle size={12} /> 0
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
|
|
||||||
{activeFile && (
|
|
||||||
<span>
|
|
||||||
Ln 1, Col 1 | Spaces: 4 | UTF-8 |{" "}
|
|
||||||
{activeFile.path?.split(".").pop()?.toUpperCase() || "TXT"}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>Web VS Code</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { GoFile } from "react-icons/go";
|
|
||||||
import { MdClose } from "react-icons/md";
|
|
||||||
import type { FileNode } from "../types";
|
|
||||||
|
|
||||||
interface TabBarProps {
|
|
||||||
openFiles: FileNode[];
|
|
||||||
activeFile: FileNode | null;
|
|
||||||
onSelectFile: (file: FileNode) => void;
|
|
||||||
onCloseFile: (file: FileNode) => void;
|
|
||||||
onCloseAll: () => void;
|
|
||||||
onCloseOthers: (file: FileNode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TabBar: React.FC<TabBarProps> = ({
|
|
||||||
openFiles,
|
|
||||||
activeFile,
|
|
||||||
onSelectFile,
|
|
||||||
onCloseFile,
|
|
||||||
onCloseAll,
|
|
||||||
onCloseOthers,
|
|
||||||
}) => {
|
|
||||||
const [showContextMenu, setShowContextMenu] = useState<{
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
file: FileNode;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent, file: FileNode) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setShowContextMenu({ x: e.clientX, y: e.clientY, file });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: "#1e1e1e",
|
|
||||||
borderBottom: "1px solid #3e3e42",
|
|
||||||
overflowX: "auto",
|
|
||||||
minHeight: "40px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
padding: "0 12px",
|
|
||||||
gap: "8px",
|
|
||||||
borderRight: "1px solid #3e3e42",
|
|
||||||
height: "100%",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
onClick={onCloseAll}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: "#cccccc",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "14px",
|
|
||||||
padding: "6px 8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
transition: "all 0.1s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
title="Close All"
|
|
||||||
>
|
|
||||||
<MdClose size={14} />
|
|
||||||
<span style={{ fontSize: "11px" }}>Close All</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{openFiles.map((file) => (
|
|
||||||
<div
|
|
||||||
key={file.path}
|
|
||||||
onClick={() => onSelectFile(file)}
|
|
||||||
onContextMenu={(e) => handleContextMenu(e, file)}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
padding: "8px 16px",
|
|
||||||
backgroundColor:
|
|
||||||
activeFile?.path === file.path ? "#1e1e1e" : "#2d2d30",
|
|
||||||
color: activeFile?.path === file.path ? "#fff" : "#cccccc",
|
|
||||||
borderRight: "1px solid #3e3e42",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "13px",
|
|
||||||
gap: "10px",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
transition: "all 0.1s",
|
|
||||||
borderTop:
|
|
||||||
activeFile?.path === file.path
|
|
||||||
? "2px solid #0e639c"
|
|
||||||
: "2px solid transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<GoFile />
|
|
||||||
<span>{file.name}</span>
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onCloseFile(file);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
color: "#858585",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "16px",
|
|
||||||
padding: "0 4px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.color = "#fff";
|
|
||||||
e.currentTarget.style.backgroundColor = "#3e3e42";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.color = "#858585";
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdClose size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{showContextMenu && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: showContextMenu.y,
|
|
||||||
left: showContextMenu.x,
|
|
||||||
backgroundColor: "#252526",
|
|
||||||
border: "1px solid #3e3e42",
|
|
||||||
borderRadius: "6px",
|
|
||||||
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
|
||||||
zIndex: 1000,
|
|
||||||
minWidth: "160px",
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
onCloseOthers(showContextMenu.file);
|
|
||||||
setShowContextMenu(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "#cccccc",
|
|
||||||
fontSize: "13px",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Close Others
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
onCloseAll();
|
|
||||||
setShowContextMenu(null);
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
padding: "8px 16px",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: "#cccccc",
|
|
||||||
fontSize: "13px",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#2a2d2e";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Close All
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { FiGitBranch, FiCheckCircle } from "react-icons/fi";
|
|
||||||
|
|
||||||
export const TitleBar: React.FC = () => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "32px",
|
|
||||||
backgroundColor: "#2d2d30",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "0 12px",
|
|
||||||
borderBottom: "1px solid #3e3e42",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "12px",
|
|
||||||
height: "12px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: "#ed6a5e",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "12px",
|
|
||||||
height: "12px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: "#f5bd4f",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "12px",
|
|
||||||
height: "12px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: "#61c454",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span style={{ color: "#cccccc", fontSize: "12px", fontWeight: 500 }}>
|
|
||||||
Web VS Code
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
||||||
<FiGitBranch size={12} color="#858585" />
|
|
||||||
<span style={{ color: "#858585", fontSize: "11px" }}>main</span>
|
|
||||||
<FiCheckCircle size={12} color="#61c454" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export { ContextMenu } from "./ContextMenu";
|
|
||||||
export { InputDialog } from "./InputDialog";
|
|
||||||
export { FileTreeItem } from "./FileTreeItem";
|
|
||||||
export { FileExplorer } from "./FileExplorer";
|
|
||||||
export { TabBar } from "./TabBar";
|
|
||||||
export { CodeEditor } from "./CodeEditor";
|
|
||||||
export { TitleBar } from "./TitleBar";
|
|
||||||
export { StatusBar } from "./StatusBar";
|
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
import type { FileNode } from "../types";
|
|
||||||
|
|
||||||
export const addPaths = (node: FileNode, parentPath: string = ""): FileNode => {
|
|
||||||
const currentPath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
|
||||||
const newNode = { ...node, path: currentPath };
|
|
||||||
if (newNode.children) {
|
|
||||||
newNode.children = newNode.children.map((child) =>
|
|
||||||
addPaths(child, currentPath),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return newNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllFolderPaths = (node: FileNode): string[] => {
|
|
||||||
let paths: string[] = [];
|
|
||||||
if (node.type === "folder") {
|
|
||||||
paths.push(node.path || node.name);
|
|
||||||
if (node.children) {
|
|
||||||
node.children.forEach((child) => {
|
|
||||||
paths = [...paths, ...getAllFolderPaths(child)];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return paths;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const findNode = (node: FileNode, path: string): FileNode | null => {
|
|
||||||
if (node.path === path) return node;
|
|
||||||
if (node.children) {
|
|
||||||
for (const child of node.children) {
|
|
||||||
const found = findNode(child, path);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const deleteNode = (node: FileNode, path: string): FileNode | null => {
|
|
||||||
if (node.path === path) return null;
|
|
||||||
|
|
||||||
if (node.children) {
|
|
||||||
const filtered = node.children.filter((child) => child.path !== path);
|
|
||||||
const mapped = filtered
|
|
||||||
.map((child) => deleteNode(child, path))
|
|
||||||
.filter((child): child is FileNode => child !== null);
|
|
||||||
return { ...node, children: mapped };
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const addNode = (
|
|
||||||
node: FileNode,
|
|
||||||
parentPath: string,
|
|
||||||
newNode: FileNode,
|
|
||||||
): FileNode => {
|
|
||||||
if (node.path === parentPath) {
|
|
||||||
const newPath = addPaths(newNode, node.path);
|
|
||||||
return { ...node, children: [...(node.children || []), newPath] };
|
|
||||||
}
|
|
||||||
if (node.children) {
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
children: node.children.map((child) =>
|
|
||||||
addNode(child, parentPath, newNode),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const renameNode = (
|
|
||||||
node: FileNode,
|
|
||||||
oldPath: string,
|
|
||||||
newName: string,
|
|
||||||
): FileNode | null => {
|
|
||||||
if (node.path === oldPath) {
|
|
||||||
const pathParts = node.path?.split("/") || [];
|
|
||||||
pathParts[pathParts.length - 1] = newName;
|
|
||||||
const newPath = pathParts.join("/");
|
|
||||||
const renamedNode = { ...node, name: newName, path: newPath };
|
|
||||||
|
|
||||||
if (renamedNode.children) {
|
|
||||||
renamedNode.children = renamedNode.children.map((child) => {
|
|
||||||
const oldChildPath = child.path || "";
|
|
||||||
const newChildPath = oldChildPath.replace(oldPath, newPath);
|
|
||||||
return (
|
|
||||||
renameNode(
|
|
||||||
child,
|
|
||||||
oldChildPath,
|
|
||||||
newChildPath.split("/").pop() || "",
|
|
||||||
) || child
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return renamedNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.children) {
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
children: node.children.map(
|
|
||||||
(child) => renameNode(child, oldPath, newName) || child,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const filterTree = (node: FileNode, query: string): FileNode | null => {
|
|
||||||
if (!query) return node;
|
|
||||||
const lowerQuery = query.toLowerCase();
|
|
||||||
|
|
||||||
if (node.type === "file") {
|
|
||||||
if (node.name.toLowerCase().includes(lowerQuery)) return node;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.children) {
|
|
||||||
const filteredChildren = node.children
|
|
||||||
.map((child) => filterTree(child, query))
|
|
||||||
.filter((child): child is FileNode => child !== null);
|
|
||||||
|
|
||||||
if (filteredChildren.length > 0) {
|
|
||||||
return { ...node, children: filteredChildren };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.name.toLowerCase().includes(lowerQuery)) return node;
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const collectPathsToExpand = (
|
|
||||||
node: FileNode,
|
|
||||||
query: string,
|
|
||||||
): Set<string> => {
|
|
||||||
const paths = new Set<string>();
|
|
||||||
if (!query) return paths;
|
|
||||||
|
|
||||||
const lowerQuery = query.toLowerCase();
|
|
||||||
|
|
||||||
const search = (n: FileNode, currentPath: string) => {
|
|
||||||
if (n.name.toLowerCase().includes(lowerQuery)) {
|
|
||||||
const pathParts = currentPath.split("/");
|
|
||||||
for (let i = 1; i < pathParts.length; i++) {
|
|
||||||
paths.add(pathParts.slice(0, i).join("/"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (n.children) {
|
|
||||||
n.children.forEach((child) => {
|
|
||||||
const childPath = child.path || `${currentPath}/${child.name}`;
|
|
||||||
search(child, childPath);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
search(node, node.path || node.name);
|
|
||||||
return paths;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLanguage = (path: string) => {
|
|
||||||
const ext = path.split(".").pop();
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
py: "python",
|
|
||||||
js: "javascript",
|
|
||||||
ts: "typescript",
|
|
||||||
jsx: "javascript",
|
|
||||||
tsx: "typescript",
|
|
||||||
json: "json",
|
|
||||||
md: "markdown",
|
|
||||||
css: "css",
|
|
||||||
html: "html",
|
|
||||||
};
|
|
||||||
return map[ext || ""] || "plaintext";
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { IDE } from "./IDE";
|
|
||||||
export { useIDEStore, initialFiles } from "./store/useIDEStore";
|
|
||||||
export type { FileNode } from "./types";
|
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import type { FileNode } from "../types";
|
|
||||||
import {
|
|
||||||
addPaths,
|
|
||||||
getAllFolderPaths,
|
|
||||||
findNode,
|
|
||||||
deleteNode,
|
|
||||||
addNode,
|
|
||||||
renameNode,
|
|
||||||
} from "../helpers/fileTree";
|
|
||||||
|
|
||||||
export const initialFiles: FileNode = {
|
|
||||||
name: "my-project",
|
|
||||||
type: "folder",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: "src",
|
|
||||||
type: "folder",
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
name: "main.py",
|
|
||||||
type: "file",
|
|
||||||
content:
|
|
||||||
'print("Hello, World!")\n\ndef main():\n print("Welcome!")\n\nif __name__ == "__main__":\n main()',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "utils.py",
|
|
||||||
type: "file",
|
|
||||||
content: "def helper():\n return 42",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "README.md",
|
|
||||||
type: "file",
|
|
||||||
content: "# My Project\n\nWelcome!",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IDEState {
|
|
||||||
// Файловая система
|
|
||||||
files: FileNode | null;
|
|
||||||
openFiles: FileNode[];
|
|
||||||
activeFile: FileNode | null;
|
|
||||||
expandedFolders: Set<string>;
|
|
||||||
searchQuery: string;
|
|
||||||
showSearch: boolean;
|
|
||||||
isInitialized: boolean;
|
|
||||||
|
|
||||||
// Диалоги и контекстные меню
|
|
||||||
contextMenu: { x: number; y: number; node: FileNode | null } | null;
|
|
||||||
dialog: {
|
|
||||||
type: "newFile" | "newFolder" | "rename";
|
|
||||||
node: FileNode | null;
|
|
||||||
} | null;
|
|
||||||
tabContextMenu: { x: number; y: number; file: FileNode } | null;
|
|
||||||
|
|
||||||
// Действия с файлами
|
|
||||||
selectFile: (node: FileNode) => void;
|
|
||||||
updateFileContent: (content: string) => void;
|
|
||||||
closeFile: (file: FileNode) => void;
|
|
||||||
closeAllFiles: () => void;
|
|
||||||
closeOtherFiles: (file: FileNode) => void;
|
|
||||||
|
|
||||||
// Действия с деревом
|
|
||||||
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => void;
|
|
||||||
toggleFolder: (path: string) => void;
|
|
||||||
expandAllFolders: () => void;
|
|
||||||
collapseAllFolders: () => void;
|
|
||||||
autoExpandPaths: (paths: Set<string>) => void;
|
|
||||||
deleteRoot: () => void;
|
|
||||||
createNewProject: () => void;
|
|
||||||
|
|
||||||
// Поиск
|
|
||||||
setSearchQuery: (query: string) => void;
|
|
||||||
toggleSearch: () => void;
|
|
||||||
|
|
||||||
// Контекстные меню и диалоги
|
|
||||||
setContextMenu: (
|
|
||||||
menu: { x: number; y: number; node: FileNode | null } | null,
|
|
||||||
) => void;
|
|
||||||
setDialog: (
|
|
||||||
dialog: {
|
|
||||||
type: "newFile" | "newFolder" | "rename";
|
|
||||||
node: FileNode | null;
|
|
||||||
} | null,
|
|
||||||
) => void;
|
|
||||||
setTabContextMenu: (
|
|
||||||
menu: { x: number; y: number; file: FileNode } | null,
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
// Инициализация
|
|
||||||
initialize: (initialFiles: FileNode) => void;
|
|
||||||
|
|
||||||
// Диалог подтверждения
|
|
||||||
handleDialogConfirm: (value: string) => void;
|
|
||||||
handleDeleteNode: (node: FileNode) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useIDEStore = create<IDEState>((set, get) => ({
|
|
||||||
// Начальное состояние
|
|
||||||
files: null,
|
|
||||||
openFiles: [],
|
|
||||||
activeFile: null,
|
|
||||||
expandedFolders: new Set(),
|
|
||||||
searchQuery: "",
|
|
||||||
showSearch: false,
|
|
||||||
isInitialized: false,
|
|
||||||
|
|
||||||
contextMenu: null,
|
|
||||||
dialog: null,
|
|
||||||
tabContextMenu: null,
|
|
||||||
|
|
||||||
// Инициализация
|
|
||||||
initialize: (initialFiles: FileNode) => {
|
|
||||||
const filesWithPaths = addPaths(initialFiles);
|
|
||||||
set({
|
|
||||||
files: filesWithPaths,
|
|
||||||
expandedFolders: new Set([filesWithPaths.path || filesWithPaths.name]),
|
|
||||||
isInitialized: true,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Выбор файла
|
|
||||||
selectFile: (node: FileNode) => {
|
|
||||||
if (node.type === "file") {
|
|
||||||
const { openFiles } = get();
|
|
||||||
if (!openFiles.find((f) => f.path === node.path)) {
|
|
||||||
set((state) => ({ openFiles: [...state.openFiles, node] }));
|
|
||||||
}
|
|
||||||
set({ activeFile: node });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Обновление содержимого файла
|
|
||||||
updateFileContent: (content: string) => {
|
|
||||||
const { activeFile } = get();
|
|
||||||
if (activeFile) {
|
|
||||||
const updatedFile = { ...activeFile, content };
|
|
||||||
set({ activeFile: updatedFile });
|
|
||||||
set((state) => ({
|
|
||||||
openFiles: state.openFiles.map((f) =>
|
|
||||||
f.path === activeFile.path ? updatedFile : f,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Закрытие файла
|
|
||||||
closeFile: (file: FileNode) => {
|
|
||||||
const { openFiles, activeFile } = get();
|
|
||||||
const newOpenFiles = openFiles.filter((f) => f.path !== file.path);
|
|
||||||
set({ openFiles: newOpenFiles });
|
|
||||||
|
|
||||||
if (activeFile?.path === file.path) {
|
|
||||||
set({ activeFile: newOpenFiles[newOpenFiles.length - 1] || null });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Закрыть все файлы
|
|
||||||
closeAllFiles: () => {
|
|
||||||
set({ openFiles: [], activeFile: null });
|
|
||||||
},
|
|
||||||
|
|
||||||
// Закрыть другие файлы
|
|
||||||
closeOtherFiles: (file: FileNode) => {
|
|
||||||
set({ openFiles: [file], activeFile: file });
|
|
||||||
},
|
|
||||||
|
|
||||||
// Обновить файловую систему
|
|
||||||
refreshFiles: (newFiles: FileNode | null, newFile?: FileNode) => {
|
|
||||||
const { openFiles, activeFile, selectFile } = get();
|
|
||||||
|
|
||||||
set({ files: newFiles });
|
|
||||||
|
|
||||||
if (!newFiles) {
|
|
||||||
set({ openFiles: [], activeFile: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedOpenFiles = openFiles
|
|
||||||
.map((f) => {
|
|
||||||
const found = findNode(newFiles, f.path || "");
|
|
||||||
return found && found.type === "file" ? found : null;
|
|
||||||
})
|
|
||||||
.filter((f): f is FileNode => f !== null);
|
|
||||||
|
|
||||||
set({ openFiles: updatedOpenFiles });
|
|
||||||
|
|
||||||
if (newFile) {
|
|
||||||
selectFile(newFile);
|
|
||||||
} else if (activeFile) {
|
|
||||||
const stillExists = findNode(newFiles, activeFile.path || "");
|
|
||||||
if (!stillExists) {
|
|
||||||
set({
|
|
||||||
activeFile: updatedOpenFiles[updatedOpenFiles.length - 1] || null,
|
|
||||||
});
|
|
||||||
} else if (stillExists.type === "file") {
|
|
||||||
set({ activeFile: stillExists });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Переключить папку
|
|
||||||
toggleFolder: (path: string) => {
|
|
||||||
set((state) => {
|
|
||||||
const newSet = new Set(state.expandedFolders);
|
|
||||||
if (newSet.has(path)) {
|
|
||||||
newSet.delete(path);
|
|
||||||
} else {
|
|
||||||
newSet.add(path);
|
|
||||||
}
|
|
||||||
return { expandedFolders: newSet };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Раскрыть все папки
|
|
||||||
expandAllFolders: () => {
|
|
||||||
const { files } = get();
|
|
||||||
if (files) {
|
|
||||||
set({ expandedFolders: new Set(getAllFolderPaths(files)) });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Свернуть все папки
|
|
||||||
collapseAllFolders: () => {
|
|
||||||
set({ expandedFolders: new Set() });
|
|
||||||
},
|
|
||||||
|
|
||||||
// Автоматически раскрыть пути
|
|
||||||
autoExpandPaths: (paths: Set<string>) => {
|
|
||||||
set((state) => ({
|
|
||||||
expandedFolders: new Set([...state.expandedFolders, ...paths]),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
// Удалить корень
|
|
||||||
deleteRoot: () => {
|
|
||||||
set({
|
|
||||||
files: null,
|
|
||||||
openFiles: [],
|
|
||||||
activeFile: null,
|
|
||||||
expandedFolders: new Set(),
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Создать новый проект
|
|
||||||
createNewProject: () => {
|
|
||||||
const newProject = addPaths(initialFiles);
|
|
||||||
set({
|
|
||||||
files: newProject,
|
|
||||||
expandedFolders: new Set([newProject.path || newProject.name]),
|
|
||||||
searchQuery: "",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Поиск
|
|
||||||
setSearchQuery: (query: string) => {
|
|
||||||
set({ searchQuery: query });
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleSearch: () => {
|
|
||||||
set((state) => ({ showSearch: !state.showSearch }));
|
|
||||||
},
|
|
||||||
|
|
||||||
// Контекстные меню и диалоги
|
|
||||||
setContextMenu: (menu) => set({ contextMenu: menu }),
|
|
||||||
setDialog: (dialog) => set({ dialog: dialog }),
|
|
||||||
setTabContextMenu: (menu) => set({ tabContextMenu: menu }),
|
|
||||||
|
|
||||||
// Подтверждение диалога
|
|
||||||
handleDialogConfirm: (value: string) => {
|
|
||||||
const { dialog, files, refreshFiles, toggleFolder, autoExpandPaths } =
|
|
||||||
get();
|
|
||||||
if (!dialog) return;
|
|
||||||
|
|
||||||
if (dialog.type === "rename" && dialog.node) {
|
|
||||||
const parentPath =
|
|
||||||
dialog.node.path?.split("/").slice(0, -1).join("/") || "";
|
|
||||||
const parentNode = parentPath ? findNode(files!, parentPath) : files;
|
|
||||||
if (
|
|
||||||
parentNode?.children?.some(
|
|
||||||
(c) =>
|
|
||||||
c.name.toLowerCase() === value.toLowerCase() &&
|
|
||||||
c.path !== dialog.node?.path,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
alert(`"${value}" already exists.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const newFiles = renameNode(
|
|
||||||
files!,
|
|
||||||
dialog.node.path || dialog.node.name,
|
|
||||||
value,
|
|
||||||
);
|
|
||||||
if (newFiles) {
|
|
||||||
refreshFiles(newFiles);
|
|
||||||
}
|
|
||||||
set({ dialog: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let parentPath: string;
|
|
||||||
|
|
||||||
if (!dialog.node) {
|
|
||||||
parentPath = files!.path || files!.name;
|
|
||||||
} else if (dialog.node.type === "folder") {
|
|
||||||
parentPath = dialog.node.path || dialog.node.name;
|
|
||||||
} else {
|
|
||||||
const pathParts = (dialog.node.path || dialog.node.name).split("/");
|
|
||||||
pathParts.pop();
|
|
||||||
parentPath = pathParts.join("/") || files!.path || files!.name;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parentNode = findNode(files!, parentPath);
|
|
||||||
if (
|
|
||||||
parentNode?.children?.some(
|
|
||||||
(c) => c.name.toLowerCase() === value.toLowerCase(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
alert(`"${value}" already exists in this folder.`);
|
|
||||||
set({ dialog: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let newFiles: FileNode | null = null;
|
|
||||||
let createdNode: FileNode | null = null;
|
|
||||||
|
|
||||||
if (dialog.type === "newFile") {
|
|
||||||
createdNode = { name: value, type: "file", content: "" };
|
|
||||||
newFiles = addNode(files!, parentPath, createdNode);
|
|
||||||
} else if (dialog.type === "newFolder") {
|
|
||||||
createdNode = { name: value, type: "folder", children: [] };
|
|
||||||
newFiles = addNode(files!, parentPath, createdNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newFiles) {
|
|
||||||
const allParentPaths: string[] = [];
|
|
||||||
let current = parentPath;
|
|
||||||
while (current) {
|
|
||||||
allParentPaths.push(current);
|
|
||||||
const parts = current.split("/");
|
|
||||||
parts.pop();
|
|
||||||
current = parts.join("/");
|
|
||||||
}
|
|
||||||
allParentPaths.forEach((p) => {
|
|
||||||
if (!get().expandedFolders.has(p)) {
|
|
||||||
toggleFolder(p);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
autoExpandPaths(new Set(allParentPaths));
|
|
||||||
|
|
||||||
if (createdNode && createdNode.type === "file") {
|
|
||||||
const findAndOpen = (node: FileNode, name: string): FileNode | null => {
|
|
||||||
if (node.name === name && node.type === "file") return node;
|
|
||||||
if (node.children) {
|
|
||||||
for (const child of node.children) {
|
|
||||||
const found = findAndOpen(child, name);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
const openedFile = findAndOpen(newFiles, value);
|
|
||||||
refreshFiles(newFiles, openedFile || undefined);
|
|
||||||
} else {
|
|
||||||
refreshFiles(newFiles);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
set({ dialog: null });
|
|
||||||
},
|
|
||||||
|
|
||||||
// Удаление узла
|
|
||||||
handleDeleteNode: (node: FileNode) => {
|
|
||||||
const { files, refreshFiles } = get();
|
|
||||||
const isRootNode = node.path === files?.path;
|
|
||||||
if (isRootNode) {
|
|
||||||
get().deleteRoot();
|
|
||||||
} else if (window.confirm(`Delete "${node.name}"?`)) {
|
|
||||||
const newFiles = deleteNode(files!, node.path || node.name);
|
|
||||||
if (newFiles) refreshFiles(newFiles);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
export interface FileNode {
|
|
||||||
name: string;
|
|
||||||
type: "file" | "folder";
|
|
||||||
content?: string;
|
|
||||||
children?: FileNode[];
|
|
||||||
path?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContextMenuState {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
node: FileNode | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DialogState {
|
|
||||||
type: "newFile" | "newFolder" | "rename";
|
|
||||||
node: FileNode | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TabContextMenuState {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
file: FileNode;
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,20 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { SSHAgentForm } from "../modules/agent/ui/SSHAgentForm";
|
import { SSHAgentForm } from "../modules/agent/ui/SSHAgentForm";
|
||||||
import { agentApiService } from "../modules/agent/api/agent.api.service";
|
import { FiPlusCircle, FiSend } from "react-icons/fi";
|
||||||
import type { SSHAgentConfig } from "../modules/agent/ui/SSHAgentForm";
|
|
||||||
import type {
|
interface SSHAgentConfig {
|
||||||
DeployAgentsRequest,
|
user: string;
|
||||||
DeployResult,
|
ip: string;
|
||||||
} from "../modules/agent/types/agent.types";
|
authMethod: string;
|
||||||
import {
|
sshKey?: string;
|
||||||
FiPlusCircle,
|
password?: string;
|
||||||
FiSend,
|
extraFields: { key: string; value: string }[];
|
||||||
FiCheck,
|
deployType: string;
|
||||||
FiX,
|
}
|
||||||
FiAlertCircle,
|
|
||||||
} from "react-icons/fi";
|
|
||||||
|
|
||||||
const createEmptyAgentConfig = (): SSHAgentConfig => ({
|
const createEmptyAgentConfig = (): SSHAgentConfig => ({
|
||||||
agentLabel: "",
|
|
||||||
user: "",
|
user: "",
|
||||||
ip: "",
|
ip: "",
|
||||||
port: 22,
|
|
||||||
authMethod: "key",
|
authMethod: "key",
|
||||||
sshKey: "",
|
sshKey: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -54,9 +50,7 @@ export const AddAgentsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Валидация
|
// Валидация
|
||||||
const isValid = agents.every((agent) => {
|
const isValid = agents.every((agent) => {
|
||||||
if (!agent.agentLabel || !agent.user || !agent.ip || !agent.port)
|
if (!agent.user || !agent.ip) return false;
|
||||||
return false;
|
|
||||||
if (agent.port < 1 || agent.port > 65535) return false;
|
|
||||||
if (agent.authMethod === "key" && !agent.sshKey) return false;
|
if (agent.authMethod === "key" && !agent.sshKey) return false;
|
||||||
if (agent.authMethod === "password" && !agent.password) return false;
|
if (agent.authMethod === "password" && !agent.password) return false;
|
||||||
return true;
|
return true;
|
||||||
@@ -72,53 +66,18 @@ export const AddAgentsPage: React.FC = () => {
|
|||||||
setSubmitError(null);
|
setSubmitError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Преобразуем данные из формы в формат API
|
// TODO: Реальный API вызов для развертывания агентов
|
||||||
const deployData: DeployAgentsRequest = {
|
console.log("Deploying agents:", agents);
|
||||||
servers: agents.map((agent) => ({
|
|
||||||
agentLabel: agent.agentLabel,
|
|
||||||
ip: agent.ip,
|
|
||||||
user: agent.user,
|
|
||||||
port: agent.port,
|
|
||||||
authMethod: agent.authMethod as "key" | "password",
|
|
||||||
deployType: (agent.deployType === "deploy"
|
|
||||||
? "docker"
|
|
||||||
: agent.deployType) as "docker" | "binary",
|
|
||||||
...(agent.authMethod === "key"
|
|
||||||
? { sshKey: agent.sshKey }
|
|
||||||
: { password: agent.password }),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Вызываем API для развертывания агентов
|
// Имитация задержки API
|
||||||
const response = await agentApiService.deployAgents(deployData);
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
// Формируем сообщение о результатах
|
|
||||||
const successCount = response.results.filter(
|
|
||||||
(r: DeployResult) => r.success,
|
|
||||||
).length;
|
|
||||||
const failCount = response.results.length - successCount;
|
|
||||||
|
|
||||||
if (failCount === 0) {
|
|
||||||
setSubmitMessage(
|
setSubmitMessage(
|
||||||
`Успешно развернуто ${successCount} агент(ов) на ${agents.length} сервер(ах)`,
|
`Успешно отправлено ${agents.length} сервер(ов) на развертывание`,
|
||||||
);
|
);
|
||||||
setAgents([createEmptyAgentConfig()]);
|
setAgents([createEmptyAgentConfig()]);
|
||||||
} else {
|
|
||||||
const errorMsg = response.results
|
|
||||||
.filter((r: DeployResult) => !r.success)
|
|
||||||
.map(
|
|
||||||
(r: DeployResult) => `${r.ip}: ${r.error || "Неизвестная ошибка"}`,
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
setSubmitMessage(`Успешно: ${successCount}, Ошибки: ${failCount}`);
|
|
||||||
setSubmitError(`Ошибки при развертывании:\n${errorMsg}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSubmitError(
|
setSubmitError("Ошибка при развертывании на серверах");
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Ошибка при развертывании агентов",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -203,26 +162,20 @@ export const AddAgentsPage: React.FC = () => {
|
|||||||
color: "var(--success-text)",
|
color: "var(--success-text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
{submitMessage}
|
||||||
<FiCheck className="mt-0.5 flex-shrink-0" size={16} />
|
|
||||||
<span>{submitMessage}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{submitError && (
|
{submitError && (
|
||||||
<div
|
<div
|
||||||
className="mb-6 p-4 rounded-lg border text-sm whitespace-pre-wrap"
|
className="mb-6 p-4 rounded-lg border text-sm"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--error-bg)",
|
backgroundColor: "var(--error-bg)",
|
||||||
borderColor: "var(--error-border)",
|
borderColor: "var(--error-border)",
|
||||||
color: "var(--error-text)",
|
color: "var(--error-text)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
{submitError}
|
||||||
<FiAlertCircle className="mt-0.5 flex-shrink-0" size={16} />
|
|
||||||
<span>{submitError}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,730 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import { agentApiService } from "@/modules/agent";
|
|
||||||
import type { TokenUser, TokenCreate, TokenUpdatePermissions, TokenPasswordReset } from "@/modules/agent";
|
|
||||||
import { FiUsers, FiUserPlus, FiEdit2, FiTrash2, FiUnlock, FiLock, FiKey, FiX, FiCheck, FiSearch } from "react-icons/fi";
|
|
||||||
|
|
||||||
export const AdminPage: React.FC = () => {
|
|
||||||
const [users, setUsers] = useState<TokenUser[]>([]);
|
|
||||||
const [inactiveUsers, setInactiveUsers] = useState<TokenUser[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
||||||
const [activeTab, setActiveTab] = useState<"active" | "inactive">("active");
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
|
|
||||||
// Modal states
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
|
||||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
|
||||||
const [selectedUser, setSelectedUser] = useState<TokenUser | null>(null);
|
|
||||||
|
|
||||||
// Form states
|
|
||||||
const [createData, setCreateData] = useState<TokenCreate>({
|
|
||||||
login: "",
|
|
||||||
name: "",
|
|
||||||
last_name: "",
|
|
||||||
password: "",
|
|
||||||
permission_admin: false,
|
|
||||||
permission_manage_agent: false,
|
|
||||||
permission_view: false,
|
|
||||||
is_active: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [editData, setEditData] = useState<TokenUpdatePermissions>({
|
|
||||||
is_active: false,
|
|
||||||
permission_admin: false,
|
|
||||||
permission_manage_agent: false,
|
|
||||||
permission_view: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [passwordData, setPasswordData] = useState<TokenPasswordReset>({
|
|
||||||
new_password: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchUsers = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const [active, inactive] = await Promise.all([
|
|
||||||
agentApiService.getUsers(),
|
|
||||||
agentApiService.getInactiveUsers(),
|
|
||||||
]);
|
|
||||||
setUsers(active);
|
|
||||||
setInactiveUsers(inactive);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Ошибка при загрузке пользователей");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchUsers();
|
|
||||||
}, [fetchUsers]);
|
|
||||||
|
|
||||||
const handleCreateUser = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await agentApiService.createUser(createData);
|
|
||||||
setSuccessMessage("Пользователь успешно создан");
|
|
||||||
setShowCreateModal(false);
|
|
||||||
setCreateData({
|
|
||||||
login: "",
|
|
||||||
name: "",
|
|
||||||
last_name: "",
|
|
||||||
password: "",
|
|
||||||
permission_admin: false,
|
|
||||||
permission_manage_agent: false,
|
|
||||||
permission_view: false,
|
|
||||||
is_active: false,
|
|
||||||
});
|
|
||||||
await fetchUsers();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Ошибка при создании пользователя");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpdatePermissions = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!selectedUser) return;
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await agentApiService.updateUserPermissions(selectedUser.login, editData);
|
|
||||||
setSuccessMessage("Права пользователя обновлены");
|
|
||||||
setShowEditModal(false);
|
|
||||||
await fetchUsers();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Ошибка при обновлении прав");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetPassword = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!selectedUser) return;
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await agentApiService.resetUserPassword(selectedUser.login, passwordData);
|
|
||||||
setSuccessMessage("Пароль изменен");
|
|
||||||
setShowPasswordModal(false);
|
|
||||||
setPasswordData({ new_password: "" });
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Ошибка при сбросе пароля");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleActivateUser = async (login: string) => {
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await agentApiService.activateUser(login);
|
|
||||||
setSuccessMessage("Пользователь активирован");
|
|
||||||
await fetchUsers();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Ошибка при активации пользователя");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeactivateUser = async (login: string) => {
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await agentApiService.deactivateUser(login);
|
|
||||||
setSuccessMessage("Пользователь деактивирован");
|
|
||||||
await fetchUsers();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Ошибка при деактивации пользователя");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteUser = async (login: string) => {
|
|
||||||
if (!confirm(`Вы уверены, что хотите удалить пользователя ${login}?`)) return;
|
|
||||||
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await agentApiService.deleteUser(login);
|
|
||||||
setSuccessMessage("Пользователь удален");
|
|
||||||
await fetchUsers();
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Ошибка при удалении пользователя");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const openEditModal = (user: TokenUser) => {
|
|
||||||
setSelectedUser(user);
|
|
||||||
setEditData({
|
|
||||||
is_active: user.is_active,
|
|
||||||
permission_admin: user.permission_admin,
|
|
||||||
permission_manage_agent: user.permission_manage_agent,
|
|
||||||
permission_view: user.permission_view,
|
|
||||||
});
|
|
||||||
setShowEditModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const openPasswordModal = (user: TokenUser) => {
|
|
||||||
setSelectedUser(user);
|
|
||||||
setPasswordData({ new_password: "" });
|
|
||||||
setShowPasswordModal(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredUsers = (activeTab === "active" ? users : inactiveUsers).filter(
|
|
||||||
(user) =>
|
|
||||||
user.login.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
||||||
user.last_name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
|
||||||
width: "100%",
|
|
||||||
padding: "10px 12px",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "14px",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelStyle: React.CSSProperties = {
|
|
||||||
display: "block",
|
|
||||||
marginBottom: "8px",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 500,
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonBaseStyle: React.CSSProperties = {
|
|
||||||
padding: "8px 16px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
border: "none",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: "pointer",
|
|
||||||
display: "inline-flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="min-h-screen py-8 px-4"
|
|
||||||
style={{ backgroundColor: "var(--bg-primary)" }}
|
|
||||||
>
|
|
||||||
<div style={{ maxWidth: "1200px", margin: "0 auto" }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
|
||||||
<div
|
|
||||||
className="w-14 h-14 rounded-xl flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
|
||||||
>
|
|
||||||
<FiUsers className="w-7 h-7" style={{ color: "var(--accent)" }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1
|
|
||||||
className="text-3xl font-bold mb-1"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
Управление пользователями
|
|
||||||
</h1>
|
|
||||||
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
|
|
||||||
Администрирование учетных записей
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
{successMessage && (
|
|
||||||
<div
|
|
||||||
className="mb-6 p-4 rounded-lg border text-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--success-bg)",
|
|
||||||
borderColor: "var(--success-border)",
|
|
||||||
color: "var(--success-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>{successMessage}</span>
|
|
||||||
<button onClick={() => setSuccessMessage(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}>
|
|
||||||
<FiX size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
className="mb-6 p-4 rounded-lg border text-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--error-bg)",
|
|
||||||
borderColor: "var(--error-border)",
|
|
||||||
color: "var(--error-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>{error}</span>
|
|
||||||
<button onClick={() => setError(null)} style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}>
|
|
||||||
<FiX size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tabs and Actions */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("active")}
|
|
||||||
className="px-4 py-2 rounded-lg border transition-all font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: activeTab === "active" ? "var(--accent)" : "var(--input-bg)",
|
|
||||||
color: activeTab === "active" ? "var(--accent-text)" : "var(--text-primary)",
|
|
||||||
borderColor: activeTab === "active" ? "var(--accent)" : "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Активные ({users.length})
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab("inactive")}
|
|
||||||
className="px-4 py-2 rounded-lg border transition-all font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: activeTab === "inactive" ? "var(--accent)" : "var(--input-bg)",
|
|
||||||
color: activeTab === "inactive" ? "var(--accent-text)" : "var(--text-primary)",
|
|
||||||
borderColor: activeTab === "inactive" ? "var(--accent)" : "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Неактивные ({inactiveUsers.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create Button */}
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--button-primary)",
|
|
||||||
color: "var(--button-primary-text)",
|
|
||||||
boxShadow: "0 4px 14px var(--shadow-color)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiUserPlus size={16} />
|
|
||||||
Создать пользователя
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
|
||||||
<div className="relative">
|
|
||||||
<FiSearch
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: "12px",
|
|
||||||
top: "50%",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Поиск по логину, имени или фамилии..."
|
|
||||||
className="w-full pl-10 pr-4 py-2.5 rounded-lg border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Users Table */}
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="text-center py-12" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Загрузка...
|
|
||||||
</div>
|
|
||||||
) : filteredUsers.length === 0 ? (
|
|
||||||
<div
|
|
||||||
className="text-center py-12 rounded-xl border border-dashed"
|
|
||||||
style={{ color: "var(--text-muted)", borderColor: "var(--border)" }}
|
|
||||||
>
|
|
||||||
Пользователи не найдены
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="rounded-xl border overflow-hidden"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr style={{ backgroundColor: "var(--bg-secondary)" }}>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Логин</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Имя</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Фамилия</th>
|
|
||||||
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Админ</th>
|
|
||||||
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Управление</th>
|
|
||||||
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Просмотр</th>
|
|
||||||
<th className="px-4 py-3 text-center text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredUsers.map((user, index) => (
|
|
||||||
<tr
|
|
||||||
key={user.id}
|
|
||||||
className="border-t"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
backgroundColor: index % 2 === 0 ? "var(--card-bg)" : "var(--bg-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 font-mono text-sm" style={{ color: "var(--text-primary)" }}>{user.login}</td>
|
|
||||||
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>{user.name}</td>
|
|
||||||
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>{user.last_name}</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
{user.permission_admin ? (
|
|
||||||
<FiCheck style={{ color: "var(--success-text)", display: "inline" }} />
|
|
||||||
) : (
|
|
||||||
<FiX style={{ color: "var(--error-text)", display: "inline" }} />
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
{user.permission_manage_agent ? (
|
|
||||||
<FiCheck style={{ color: "var(--success-text)", display: "inline" }} />
|
|
||||||
) : (
|
|
||||||
<FiX style={{ color: "var(--error-text)", display: "inline" }} />
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
{user.permission_view ? (
|
|
||||||
<FiCheck style={{ color: "var(--success-text)", display: "inline" }} />
|
|
||||||
) : (
|
|
||||||
<FiX style={{ color: "var(--error-text)", display: "inline" }} />
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-center">
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => openEditModal(user)}
|
|
||||||
className="p-2 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "var(--accent-text)",
|
|
||||||
}}
|
|
||||||
title="Редактировать права"
|
|
||||||
>
|
|
||||||
<FiEdit2 size={14} />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => openPasswordModal(user)}
|
|
||||||
className="p-2 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--warning-bg)",
|
|
||||||
color: "var(--warning-text)",
|
|
||||||
}}
|
|
||||||
title="Сбросить пароль"
|
|
||||||
>
|
|
||||||
<FiKey size={14} />
|
|
||||||
</button>
|
|
||||||
{activeTab === "active" ? (
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeactivateUser(user.login)}
|
|
||||||
className="p-2 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--warning-bg)",
|
|
||||||
color: "var(--warning-text)",
|
|
||||||
}}
|
|
||||||
title="Деактивировать"
|
|
||||||
>
|
|
||||||
<FiLock size={14} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => handleActivateUser(user.login)}
|
|
||||||
className="p-2 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--success-bg)",
|
|
||||||
color: "var(--success-text)",
|
|
||||||
border: "1px solid var(--success-border)",
|
|
||||||
}}
|
|
||||||
title="Активировать"
|
|
||||||
>
|
|
||||||
<FiUnlock size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteUser(user.login)}
|
|
||||||
className="p-2 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--error-bg)",
|
|
||||||
color: "var(--error-text)",
|
|
||||||
border: "1px solid var(--error-border)",
|
|
||||||
}}
|
|
||||||
title="Удалить"
|
|
||||||
>
|
|
||||||
<FiTrash2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Create User Modal */}
|
|
||||||
{showCreateModal && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div
|
|
||||||
className="rounded-2xl shadow-2xl border w-full max-w-md"
|
|
||||||
style={{ backgroundColor: "var(--card-bg)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: "var(--border)" }}>
|
|
||||||
<h2 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>Создать пользователя</h2>
|
|
||||||
<button onClick={() => setShowCreateModal(false)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-secondary)" }}>
|
|
||||||
<FiX size={24} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleCreateUser} className="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>Логин *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={createData.login}
|
|
||||||
onChange={(e) => setCreateData({ ...createData, login: e.target.value })}
|
|
||||||
required
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>Имя *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={createData.name}
|
|
||||||
onChange={(e) => setCreateData({ ...createData, name: e.target.value })}
|
|
||||||
required
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>Фамилия *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={createData.last_name}
|
|
||||||
onChange={(e) => setCreateData({ ...createData, last_name: e.target.value })}
|
|
||||||
required
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>Пароль *</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={createData.password}
|
|
||||||
onChange={(e) => setCreateData({ ...createData, password: e.target.value })}
|
|
||||||
required
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={createData.permission_admin}
|
|
||||||
onChange={(e) => setCreateData({ ...createData, permission_admin: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>Администратор</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={createData.permission_manage_agent}
|
|
||||||
onChange={(e) => setCreateData({ ...createData, permission_manage_agent: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>Управление агентами</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={createData.permission_view}
|
|
||||||
onChange={(e) => setCreateData({ ...createData, permission_view: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>Просмотр</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={createData.is_active}
|
|
||||||
onChange={(e) => setCreateData({ ...createData, is_active: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>Активен сразу</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCreateModal(false)}
|
|
||||||
className="flex-1 px-4 py-2 rounded-lg border transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1 px-4 py-2 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--button-primary)",
|
|
||||||
color: "var(--button-primary-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Создать
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit Permissions Modal */}
|
|
||||||
{showEditModal && selectedUser && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div
|
|
||||||
className="rounded-2xl shadow-2xl border w-full max-w-md"
|
|
||||||
style={{ backgroundColor: "var(--card-bg)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: "var(--border)" }}>
|
|
||||||
<h2 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>
|
|
||||||
Редактировать: {selectedUser.login}
|
|
||||||
</h2>
|
|
||||||
<button onClick={() => setShowEditModal(false)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-secondary)" }}>
|
|
||||||
<FiX size={24} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleUpdatePermissions} className="p-6 space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editData.permission_admin || false}
|
|
||||||
onChange={(e) => setEditData({ ...editData, permission_admin: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>Администратор</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editData.permission_manage_agent || false}
|
|
||||||
onChange={(e) => setEditData({ ...editData, permission_manage_agent: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>Управление агентами</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editData.permission_view || false}
|
|
||||||
onChange={(e) => setEditData({ ...editData, permission_view: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>Просмотр</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={editData.is_active || false}
|
|
||||||
onChange={(e) => setEditData({ ...editData, is_active: e.target.checked })}
|
|
||||||
/>
|
|
||||||
<span style={{ color: "var(--text-primary)" }}>Активен</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowEditModal(false)}
|
|
||||||
className="flex-1 px-4 py-2 rounded-lg border transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1 px-4 py-2 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--button-primary)",
|
|
||||||
color: "var(--button-primary-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reset Password Modal */}
|
|
||||||
{showPasswordModal && selectedUser && (
|
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div
|
|
||||||
className="rounded-2xl shadow-2xl border w-full max-w-md"
|
|
||||||
style={{ backgroundColor: "var(--card-bg)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between p-6 border-b" style={{ borderColor: "var(--border)" }}>
|
|
||||||
<h2 className="text-xl font-bold" style={{ color: "var(--text-primary)" }}>
|
|
||||||
Сброс пароля: {selectedUser.login}
|
|
||||||
</h2>
|
|
||||||
<button onClick={() => setShowPasswordModal(false)} style={{ background: "none", border: "none", cursor: "pointer", color: "var(--text-secondary)" }}>
|
|
||||||
<FiX size={24} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleResetPassword} className="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>Новый пароль *</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={passwordData.new_password}
|
|
||||||
onChange={(e) => setPasswordData({ new_password: e.target.value })}
|
|
||||||
required
|
|
||||||
style={inputStyle}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 pt-4">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowPasswordModal(false)}
|
|
||||||
className="flex-1 px-4 py-2 rounded-lg border transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex-1 px-4 py-2 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--button-primary)",
|
|
||||||
color: "var(--button-primary-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сбросить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { IDE } from "../modules/ide";
|
|
||||||
|
|
||||||
export const IDEPage = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
return (
|
|
||||||
<div className="absolute top-0 left-0 w-full h-full z-90">
|
|
||||||
<IDE
|
|
||||||
onBack={() => navigate("/home")}
|
|
||||||
initialFiles={{ name: "тест", type: "folder" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from "react";
|
|
||||||
import { agentApiService } from "@/modules/agent";
|
|
||||||
import type { LogEntry } from "@/modules/agent";
|
|
||||||
import { LogFilters } from "@/modules/agent/ui/LogFilters";
|
|
||||||
import { useLogFilterStore } from "@/modules/agent/store/logFilter.store";
|
|
||||||
import {
|
|
||||||
FiFileText,
|
|
||||||
FiRefreshCw,
|
|
||||||
FiChevronLeft,
|
|
||||||
FiChevronRight,
|
|
||||||
FiInfo,
|
|
||||||
FiAlertTriangle,
|
|
||||||
FiAlertCircle,
|
|
||||||
FiXOctagon,
|
|
||||||
} from "react-icons/fi";
|
|
||||||
|
|
||||||
const logLevelIcons: Record<string, React.ReactNode> = {
|
|
||||||
INFO: <FiInfo size={14} />,
|
|
||||||
WARNING: <FiAlertTriangle size={14} />,
|
|
||||||
ERROR: <FiAlertCircle size={14} />,
|
|
||||||
FATAL: <FiXOctagon size={14} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const logLevelColors: Record<string, { bg: string; text: string; border: string }> = {
|
|
||||||
INFO: { bg: "var(--info-bg)", text: "var(--info-text)", border: "var(--info-border)" },
|
|
||||||
WARNING: { bg: "var(--warning-bg)", text: "var(--warning-text)", border: "var(--warning-border)" },
|
|
||||||
ERROR: { bg: "var(--error-bg)", text: "var(--error-text)", border: "var(--error-border)" },
|
|
||||||
FATAL: { bg: "var(--fatal-bg)", text: "var(--fatal-text)", border: "var(--fatal-border)" },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LogsPage: React.FC = () => {
|
|
||||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [availableServices, setAvailableServices] = useState<string[]>([]);
|
|
||||||
const [availableAgents, setAvailableAgents] = useState<string[]>([]);
|
|
||||||
const [totalLogs, setTotalLogs] = useState(0);
|
|
||||||
|
|
||||||
const { getFilters, limit, offset, setOffset } = useLogFilterStore();
|
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const filters = getFilters();
|
|
||||||
const data = await agentApiService.searchLogs(filters);
|
|
||||||
setLogs(data);
|
|
||||||
setTotalLogs(data.length);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : "Ошибка при загрузке логов");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, [getFilters]);
|
|
||||||
|
|
||||||
const fetchDistinctData = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const [services, agents] = await Promise.all([
|
|
||||||
agentApiService.getDistinctServices(),
|
|
||||||
agentApiService.getDistinctAgents(),
|
|
||||||
]);
|
|
||||||
setAvailableServices(services);
|
|
||||||
setAvailableAgents(agents);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to fetch distinct data:", err);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchDistinctData();
|
|
||||||
}, [fetchDistinctData]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchLogs();
|
|
||||||
}, [fetchLogs, offset, limit]);
|
|
||||||
|
|
||||||
const handleFilterApply = () => {
|
|
||||||
setOffset(0);
|
|
||||||
fetchLogs();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNextPage = () => {
|
|
||||||
setOffset(offset + limit);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePrevPage = () => {
|
|
||||||
setOffset(Math.max(0, offset - limit));
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTimestamp = (timestamp: string) => {
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
return date.toLocaleString("ru-RU", {
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="min-h-screen py-8 px-4"
|
|
||||||
style={{ backgroundColor: "var(--bg-primary)" }}
|
|
||||||
>
|
|
||||||
<div style={{ maxWidth: "1400px", margin: "0 auto" }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
|
||||||
<div
|
|
||||||
className="w-14 h-14 rounded-xl flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
|
||||||
>
|
|
||||||
<FiFileText className="w-7 h-7" style={{ color: "var(--accent)" }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1
|
|
||||||
className="text-3xl font-bold mb-1"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
Поиск логов
|
|
||||||
</h1>
|
|
||||||
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
|
|
||||||
Фильтрация и анализ логов системы
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<LogFilters
|
|
||||||
onApply={handleFilterApply}
|
|
||||||
availableServices={availableServices}
|
|
||||||
availableAgents={availableAgents}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error Message */}
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
className="mb-6 p-4 rounded-lg border text-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--error-bg)",
|
|
||||||
borderColor: "var(--error-border)",
|
|
||||||
color: "var(--error-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Logs Table */}
|
|
||||||
<div
|
|
||||||
className="rounded-xl border overflow-hidden"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Table Header */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-b" style={{ borderColor: "var(--border)" }}>
|
|
||||||
<span className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>
|
|
||||||
Найдено: {totalLogs} записей
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={fetchLogs}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all text-xs font-medium border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
color: "var(--accent)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiRefreshCw size={12} className={isLoading ? "animate-spin" : ""} />
|
|
||||||
Обновить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex items-center justify-center py-12" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
<FiRefreshCw size={24} className="animate-spin mr-3" />
|
|
||||||
Загрузка логов...
|
|
||||||
</div>
|
|
||||||
) : logs.length === 0 ? (
|
|
||||||
<div className="text-center py-12" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Логи не найдены
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr style={{ backgroundColor: "var(--bg-secondary)" }}>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Время
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Уровень
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Сервис
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Агент
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Сообщение
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{logs.map((log, index) => {
|
|
||||||
const colors = logLevelColors[log.level] || logLevelColors.INFO;
|
|
||||||
return (
|
|
||||||
<tr
|
|
||||||
key={index}
|
|
||||||
className="border-t"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
backgroundColor: index % 2 === 0 ? "var(--card-bg)" : "var(--bg-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 text-sm font-mono whitespace-nowrap" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{formatTimestamp(log.timestamp)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: colors.bg,
|
|
||||||
color: colors.text,
|
|
||||||
borderColor: colors.border,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{logLevelIcons[log.level]}
|
|
||||||
{log.level}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{log.service}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm font-mono" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{log.agent}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm" style={{ color: "var(--text-primary)" }}>
|
|
||||||
{log.message}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: "var(--border)" }}>
|
|
||||||
<button
|
|
||||||
onClick={handlePrevPage}
|
|
||||||
disabled={offset === 0}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiChevronLeft size={16} />
|
|
||||||
Назад
|
|
||||||
</button>
|
|
||||||
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Показано {logs.length} записей (смещение: {offset})
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={handleNextPage}
|
|
||||||
disabled={logs.length < limit}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Далее
|
|
||||||
<FiChevronRight size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -5,7 +5,7 @@ import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
|||||||
|
|
||||||
export const RegisterPage: React.FC = () => {
|
export const RegisterPage: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { register, isLoading, error, clearError } = useAuthStore();
|
const { register, isLoading, error, clearError, token } = useAuthStore();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
login: "",
|
login: "",
|
||||||
password: "",
|
password: "",
|
||||||
@@ -14,7 +14,12 @@ export const RegisterPage: React.FC = () => {
|
|||||||
lastName: "",
|
lastName: "",
|
||||||
});
|
});
|
||||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
navigate("/");
|
||||||
|
}
|
||||||
|
}, [token, navigate]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -33,17 +38,7 @@ export const RegisterPage: React.FC = () => {
|
|||||||
firstName: formData.firstName,
|
firstName: formData.firstName,
|
||||||
lastName: formData.lastName,
|
lastName: formData.lastName,
|
||||||
});
|
});
|
||||||
setSuccessMessage("Аккаунт успешно создан! Теперь вы можете войти.");
|
navigate("/");
|
||||||
setFormData({
|
|
||||||
login: "",
|
|
||||||
password: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
firstName: "",
|
|
||||||
lastName: "",
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate("/auth");
|
|
||||||
}, 2000);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Error is handled by store
|
// Error is handled by store
|
||||||
}
|
}
|
||||||
@@ -87,10 +82,7 @@ export const RegisterPage: React.FC = () => {
|
|||||||
className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center"
|
className="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center"
|
||||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
style={{ backgroundColor: "var(--bg-secondary)" }}
|
||||||
>
|
>
|
||||||
<FiUserPlus
|
<FiUserPlus className="w-8 h-8" style={{ color: "var(--accent)" }} />
|
||||||
className="w-8 h-8"
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<h1
|
<h1
|
||||||
className="text-3xl font-bold mb-2"
|
className="text-3xl font-bold mb-2"
|
||||||
@@ -117,20 +109,6 @@ export const RegisterPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Success Message */}
|
|
||||||
{successMessage && (
|
|
||||||
<div
|
|
||||||
className="mb-6 p-4 rounded-lg border text-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--success-bg)",
|
|
||||||
borderColor: "var(--success-border)",
|
|
||||||
color: "var(--success-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{successMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Form */}
|
{/* Form */}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
{/* Name Fields */}
|
{/* Name Fields */}
|
||||||
@@ -315,16 +293,8 @@ export const RegisterPage: React.FC = () => {
|
|||||||
className="mt-2 text-sm flex items-center gap-1"
|
className="mt-2 text-sm flex items-center gap-1"
|
||||||
style={{ color: "var(--error-text)" }}
|
style={{ color: "var(--error-text)" }}
|
||||||
>
|
>
|
||||||
<svg
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
className="w-4 h-4"
|
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
{passwordError}
|
{passwordError}
|
||||||
</p>
|
</p>
|
||||||
@@ -341,8 +311,7 @@ export const RegisterPage: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!isLoading) {
|
if (!isLoading) {
|
||||||
e.currentTarget.style.backgroundColor =
|
e.currentTarget.style.backgroundColor = "var(--button-primary-hover)";
|
||||||
"var(--button-primary-hover)";
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
|
|||||||
@@ -1,423 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
|
||||||
import { FiKey, FiPlus, FiTrash2, FiCopy, FiCheck, FiX } from "react-icons/fi";
|
|
||||||
|
|
||||||
interface RegistrationTokenForm {
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RegistrationResult {
|
|
||||||
label: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RegistrationTokenPage: React.FC = () => {
|
|
||||||
const [tokens, setTokens] = useState<RegistrationTokenForm[]>([
|
|
||||||
{ label: "" },
|
|
||||||
]);
|
|
||||||
const [results, setResults] = useState<RegistrationResult[]>([]);
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
||||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const handleTokenChange = (index: number, label: string) => {
|
|
||||||
const newTokens = [...tokens];
|
|
||||||
newTokens[index] = { label };
|
|
||||||
setTokens(newTokens);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddToken = () => {
|
|
||||||
setTokens([...tokens, { label: "" }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveToken = (index: number) => {
|
|
||||||
const newTokens = tokens.filter((_, i) => i !== index);
|
|
||||||
setTokens(newTokens);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyToken = async (token: string, index: number) => {
|
|
||||||
await navigator.clipboard.writeText(token);
|
|
||||||
setCopiedIndex(index);
|
|
||||||
setTimeout(() => setCopiedIndex(null), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// Валидация
|
|
||||||
const validTokens = tokens.filter((t) => t.label.trim());
|
|
||||||
if (validTokens.length === 0) {
|
|
||||||
setError("Введите хотя бы одну метку для токена");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
|
||||||
setError(null);
|
|
||||||
setSuccessMessage(null);
|
|
||||||
setResults([]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const createdTokens: RegistrationResult[] = [];
|
|
||||||
|
|
||||||
for (const tokenData of validTokens) {
|
|
||||||
const response = await agentApiService.createRegistrationToken({
|
|
||||||
label: tokenData.label,
|
|
||||||
});
|
|
||||||
|
|
||||||
// API возвращает объект с токеном
|
|
||||||
const token = response.token || Object.values(response)[0] as string;
|
|
||||||
|
|
||||||
createdTokens.push({
|
|
||||||
label: tokenData.label,
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setResults(createdTokens);
|
|
||||||
setSuccessMessage(
|
|
||||||
`Успешно создано ${createdTokens.length} токен(ов)`
|
|
||||||
);
|
|
||||||
setTokens([{ label: "" }]);
|
|
||||||
} catch (err) {
|
|
||||||
setError(
|
|
||||||
err instanceof Error
|
|
||||||
? err.message
|
|
||||||
: "Ошибка при создании токенов"
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
|
||||||
width: "100%",
|
|
||||||
padding: "10px 12px",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "14px",
|
|
||||||
transition: "border-color 0.2s, box-shadow 0.2s",
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelStyle: React.CSSProperties = {
|
|
||||||
display: "block",
|
|
||||||
marginBottom: "8px",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 500,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="min-h-screen py-8 px-4"
|
|
||||||
style={{ backgroundColor: "var(--bg-primary)" }}
|
|
||||||
>
|
|
||||||
<div style={{ maxWidth: "900px", margin: "0 auto" }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8">
|
|
||||||
<div className="flex items-center gap-4 mb-4">
|
|
||||||
<div
|
|
||||||
className="w-14 h-14 rounded-xl flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
|
||||||
>
|
|
||||||
<FiKey className="w-7 h-7" style={{ color: "var(--accent)" }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1
|
|
||||||
className="text-3xl font-bold mb-1"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
Регистрация токенов для агентов
|
|
||||||
</h1>
|
|
||||||
<p style={{ color: "var(--text-secondary)", fontSize: "16px" }}>
|
|
||||||
Создайте токены для регистрации новых агентов
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
{/* Token Forms */}
|
|
||||||
<div className="space-y-5">
|
|
||||||
{tokens.map((token, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="rounded-2xl shadow-lg border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
padding: "24px",
|
|
||||||
marginBottom: "20px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: "20px",
|
|
||||||
paddingBottom: "16px",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
|
||||||
<div
|
|
||||||
className="w-10 h-10 rounded-lg flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: "var(--bg-secondary)" }}
|
|
||||||
>
|
|
||||||
<FiKey style={{ color: "var(--accent)", fontSize: "20px" }} />
|
|
||||||
</div>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "18px",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Токен #{index + 1}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{tokens.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleRemoveToken(index)}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
background: "var(--error-bg)",
|
|
||||||
color: "var(--error-text)",
|
|
||||||
border: "1px solid var(--error-border)",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = "var(--error-text)";
|
|
||||||
e.currentTarget.style.color = "#fff";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = "var(--error-bg)";
|
|
||||||
e.currentTarget.style.color = "var(--error-text)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiTrash2 size={14} />
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Label Input */}
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
|
||||||
>
|
|
||||||
<FiKey size={14} />
|
|
||||||
Метка токена *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={token.label}
|
|
||||||
onChange={(e) => handleTokenChange(index, e.target.value)}
|
|
||||||
required
|
|
||||||
style={inputStyle}
|
|
||||||
onFocus={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = "var(--border-focus)";
|
|
||||||
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
|
|
||||||
}}
|
|
||||||
onBlur={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = "var(--border)";
|
|
||||||
e.currentTarget.style.boxShadow = "none";
|
|
||||||
}}
|
|
||||||
placeholder="agent-production-1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Token Button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleAddToken}
|
|
||||||
className="w-full flex items-center justify-center gap-2 py-3.5 px-4 rounded-xl border-2 border-dashed transition-all mb-6 font-medium"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
color: "var(--accent)",
|
|
||||||
fontSize: "15px",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = "var(--accent)";
|
|
||||||
e.currentTarget.style.backgroundColor = "var(--accent)10";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.borderColor = "var(--border)";
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiPlus size={18} />
|
|
||||||
Добавить ещё один токен
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Messages */}
|
|
||||||
{successMessage && (
|
|
||||||
<div
|
|
||||||
className="mb-6 p-4 rounded-lg border text-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--success-bg)",
|
|
||||||
borderColor: "var(--success-border)",
|
|
||||||
color: "var(--success-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>{successMessage}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setSuccessMessage(null)}
|
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}
|
|
||||||
>
|
|
||||||
<FiX size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
className="mb-6 p-4 rounded-lg border text-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--error-bg)",
|
|
||||||
borderColor: "var(--error-border)",
|
|
||||||
color: "var(--error-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>{error}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setError(null)}
|
|
||||||
style={{ background: "none", border: "none", cursor: "pointer", color: "inherit" }}
|
|
||||||
>
|
|
||||||
<FiX size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Submit Button */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-3.5 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium text-base mb-8"
|
|
||||||
style={{
|
|
||||||
backgroundColor: isSubmitting
|
|
||||||
? "var(--bg-secondary)"
|
|
||||||
: "var(--button-primary)",
|
|
||||||
color: "var(--button-primary-text)",
|
|
||||||
boxShadow: isSubmitting
|
|
||||||
? "none"
|
|
||||||
: "0 4px 14px var(--shadow-color)",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isSubmitting) {
|
|
||||||
e.currentTarget.style.backgroundColor =
|
|
||||||
"var(--button-primary-hover)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = isSubmitting
|
|
||||||
? "var(--bg-secondary)"
|
|
||||||
: "var(--button-primary)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
|
||||||
Создание токенов...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FiKey size={18} />
|
|
||||||
Создать {tokens.filter((t) => t.label.trim()).length || 1} токен(ов)
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Results */}
|
|
||||||
{results.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<h2
|
|
||||||
className="text-xl font-bold mb-4"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
Созданные токены
|
|
||||||
</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{results.map((result, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="rounded-xl border p-4"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<span
|
|
||||||
className="font-medium"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
{result.label}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleCopyToken(result.token, index)}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all text-xs font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
copiedIndex === index
|
|
||||||
? "var(--success-text)"
|
|
||||||
: "var(--accent)",
|
|
||||||
color:
|
|
||||||
copiedIndex === index
|
|
||||||
? "#fff"
|
|
||||||
: "var(--accent-text)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{copiedIndex === index ? (
|
|
||||||
<>
|
|
||||||
<FiCheck size={12} />
|
|
||||||
Скопировано
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FiCopy size={12} />
|
|
||||||
Копировать
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<code
|
|
||||||
className="block p-3 rounded-lg text-xs font-mono break-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
color: "var(--accent)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{result.token}
|
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,534 +0,0 @@
|
|||||||
import React, { useRef, useState, useEffect } from "react";
|
|
||||||
import ForceGraph2D from "react-force-graph-2d";
|
|
||||||
import {
|
|
||||||
FiDownload,
|
|
||||||
FiZoomIn,
|
|
||||||
FiZoomOut,
|
|
||||||
FiMove,
|
|
||||||
FiCpu,
|
|
||||||
FiServer,
|
|
||||||
FiPlus,
|
|
||||||
FiTrash2,
|
|
||||||
FiLink,
|
|
||||||
FiMinusCircle,
|
|
||||||
} from "react-icons/fi";
|
|
||||||
|
|
||||||
interface GraphNode {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: "agent" | "service";
|
|
||||||
val?: number;
|
|
||||||
description?: string;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphLink {
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphData {
|
|
||||||
nodes: GraphNode[];
|
|
||||||
links: GraphLink[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CustomGraphProps {
|
|
||||||
data: GraphData;
|
|
||||||
onExport?: () => void;
|
|
||||||
onDataChange?: (newData: GraphData) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Test2Page: React.FC<CustomGraphProps> = ({
|
|
||||||
data: initialData,
|
|
||||||
onExport,
|
|
||||||
onDataChange,
|
|
||||||
}) => {
|
|
||||||
const fgRef = useRef<any>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [data, setData] = useState<GraphData>(initialData);
|
|
||||||
const [highlightNodes, setHighlightNodes] = useState<Set<string>>(new Set());
|
|
||||||
const [highlightLinks, setHighlightLinks] = useState<Set<any>>(new Set());
|
|
||||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
|
|
||||||
const [isLinkMode, setIsLinkMode] = useState(false);
|
|
||||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
|
|
||||||
const [contextMenu, setContextMenu] = useState<{
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
node: GraphNode | null;
|
|
||||||
link: GraphLink | null;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData) setData(initialData);
|
|
||||||
}, [initialData]);
|
|
||||||
|
|
||||||
// Отслеживаем размеры контейнера через ResizeObserver
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const updateDimensions = () => {
|
|
||||||
setDimensions({
|
|
||||||
width: container.clientWidth,
|
|
||||||
height: container.clientHeight || window.innerHeight - 160,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
updateDimensions();
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(updateDimensions);
|
|
||||||
observer.observe(container);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Функция для подсветки связанных элементов
|
|
||||||
const handleNodeHover = (node: GraphNode | null) => {
|
|
||||||
const newHighlightNodes = new Set<string>();
|
|
||||||
const newHighlightLinks = new Set<any>();
|
|
||||||
|
|
||||||
if (node) {
|
|
||||||
newHighlightNodes.add(node.id);
|
|
||||||
data.links.forEach((link) => {
|
|
||||||
if (link.source === node.id || link.target === node.id) {
|
|
||||||
newHighlightLinks.add(link);
|
|
||||||
newHighlightNodes.add(link.source as string);
|
|
||||||
newHighlightNodes.add(link.target as string);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setHighlightNodes(newHighlightNodes);
|
|
||||||
setHighlightLinks(newHighlightLinks);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Обработчик клика по узлу для создания связей
|
|
||||||
const handleNodeClick = (node: GraphNode) => {
|
|
||||||
if (isLinkMode) {
|
|
||||||
if (selectedNode === null) {
|
|
||||||
setSelectedNode(node);
|
|
||||||
} else if (selectedNode.id !== node.id) {
|
|
||||||
const newLink: GraphLink = {
|
|
||||||
source: selectedNode.id,
|
|
||||||
target: node.id,
|
|
||||||
type: "custom",
|
|
||||||
};
|
|
||||||
|
|
||||||
const linkExists = data.links.some(
|
|
||||||
(link) =>
|
|
||||||
(link.source === selectedNode.id && link.target === node.id) ||
|
|
||||||
(link.source === node.id && link.target === selectedNode.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!linkExists) {
|
|
||||||
const newData = {
|
|
||||||
nodes: [...data.nodes],
|
|
||||||
links: [...data.links, newLink],
|
|
||||||
};
|
|
||||||
setData(newData);
|
|
||||||
onDataChange?.(newData);
|
|
||||||
}
|
|
||||||
setSelectedNode(null);
|
|
||||||
setIsLinkMode(false);
|
|
||||||
} else {
|
|
||||||
setSelectedNode(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// УДАЛЕНИЕ СВЯЗИ
|
|
||||||
const handleDeleteLink = (linkToDelete: GraphLink) => {
|
|
||||||
const filteredLinks = data.links.filter((link) => link !== linkToDelete);
|
|
||||||
const newData = {
|
|
||||||
nodes: [...data.nodes],
|
|
||||||
links: filteredLinks,
|
|
||||||
};
|
|
||||||
setData(newData);
|
|
||||||
onDataChange?.(newData);
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// УДАЛЕНИЕ УЗЛА
|
|
||||||
const handleDeleteNode = (nodeToDelete: GraphNode) => {
|
|
||||||
const filteredNodes = data.nodes.filter(
|
|
||||||
(node) => node.id !== nodeToDelete.id,
|
|
||||||
);
|
|
||||||
const filteredLinks = data.links.filter(
|
|
||||||
(link) =>
|
|
||||||
link.source !== nodeToDelete.id && link.target !== nodeToDelete.id,
|
|
||||||
);
|
|
||||||
const newData = {
|
|
||||||
nodes: filteredNodes,
|
|
||||||
links: filteredLinks,
|
|
||||||
};
|
|
||||||
setData(newData);
|
|
||||||
onDataChange?.(newData);
|
|
||||||
setContextMenu(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Добавление нового узла
|
|
||||||
const handleAddNode = () => {
|
|
||||||
const newNodeName = prompt(
|
|
||||||
"Введите имя узла:",
|
|
||||||
`Node ${data.nodes.length + 1}`,
|
|
||||||
);
|
|
||||||
if (newNodeName) {
|
|
||||||
const isService = window.confirm(
|
|
||||||
"Выберите тип: OK - Сервис, Отмена - Агент",
|
|
||||||
);
|
|
||||||
const newNode: GraphNode = {
|
|
||||||
id: `node-${Date.now()}`,
|
|
||||||
name: newNodeName,
|
|
||||||
type: isService ? "service" : "agent",
|
|
||||||
val: isService ? 12 : 8,
|
|
||||||
description: "Новый узел",
|
|
||||||
};
|
|
||||||
|
|
||||||
const newData = {
|
|
||||||
nodes: [...data.nodes, newNode],
|
|
||||||
links: [...data.links],
|
|
||||||
};
|
|
||||||
setData(newData);
|
|
||||||
onDataChange?.(newData);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Открытие контекстного меню
|
|
||||||
const openContextMenu = (
|
|
||||||
e: React.MouseEvent,
|
|
||||||
node?: GraphNode,
|
|
||||||
link?: GraphLink,
|
|
||||||
) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (node) {
|
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, node, link: null });
|
|
||||||
} else if (link) {
|
|
||||||
setContextMenu({ x: e.clientX, y: e.clientY, node: null, link });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Закрыть контекстное меню
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = () => setContextMenu(null);
|
|
||||||
document.addEventListener("click", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("click", handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Функция для определения цвета узла
|
|
||||||
const getNodeColor = (node: GraphNode) => {
|
|
||||||
if (highlightNodes.has(node.id)) return "#fbbf24";
|
|
||||||
if (selectedNode?.id === node.id && isLinkMode) return "#f97316";
|
|
||||||
|
|
||||||
switch (node.type) {
|
|
||||||
case "service":
|
|
||||||
return "#3b82f6";
|
|
||||||
case "agent":
|
|
||||||
return "#8b5cf6";
|
|
||||||
default:
|
|
||||||
return "#6b7280";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Функция для размера узла
|
|
||||||
const getNodeSize = (node: GraphNode) => {
|
|
||||||
switch (node.type) {
|
|
||||||
case "service":
|
|
||||||
return 3;
|
|
||||||
case "agent":
|
|
||||||
return 3;
|
|
||||||
default:
|
|
||||||
return 5;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Кастомный рендер узла
|
|
||||||
const renderNode = (
|
|
||||||
node: GraphNode,
|
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
globalScale: number,
|
|
||||||
) => {
|
|
||||||
const size = getNodeSize(node);
|
|
||||||
const color = getNodeColor(node);
|
|
||||||
|
|
||||||
if (!node.x || !node.y) return;
|
|
||||||
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.arc(node.x, node.y, size, 0, 2 * Math.PI);
|
|
||||||
ctx.fillStyle = color;
|
|
||||||
ctx.fill();
|
|
||||||
|
|
||||||
ctx.fillStyle = "#ffffff";
|
|
||||||
ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", sans-serif`;
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.textBaseline = "middle";
|
|
||||||
|
|
||||||
if (node.type === "service") {
|
|
||||||
ctx.fillText("S", node.x, node.y);
|
|
||||||
} else if (node.type === "agent") {
|
|
||||||
ctx.fillText("A", node.x, node.y);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (globalScale > 0.5) {
|
|
||||||
ctx.fillStyle = "#e5e7eb";
|
|
||||||
ctx.font = `${Math.min(12, 12 / globalScale)}px "Arial", sans-serif`;
|
|
||||||
ctx.textAlign = "center";
|
|
||||||
ctx.fillText(node.name, node.x, node.y + size + 8);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
if (onExport) {
|
|
||||||
onExport();
|
|
||||||
} else {
|
|
||||||
const dataStr = JSON.stringify(data, null, 2);
|
|
||||||
const blob = new Blob([dataStr], { type: "application/json" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const link = document.createElement("a");
|
|
||||||
link.href = url;
|
|
||||||
link.download = "graph-data.json";
|
|
||||||
link.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleZoomIn = () => {
|
|
||||||
if (fgRef.current) {
|
|
||||||
const currentZoom = fgRef.current.zoom();
|
|
||||||
fgRef.current.zoom(currentZoom * 1.2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleZoomOut = () => {
|
|
||||||
if (fgRef.current) {
|
|
||||||
const currentZoom = fgRef.current.zoom();
|
|
||||||
fgRef.current.zoom(currentZoom / 1.2);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFit = () => {
|
|
||||||
if (fgRef.current) {
|
|
||||||
fgRef.current.zoomToFit(400);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!data || data.nodes.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
|
|
||||||
<div className="flex items-center justify-center h-96">
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-gray-400 mb-4">Нет данных для отображения</p>
|
|
||||||
<button
|
|
||||||
onClick={handleAddNode}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 rounded-lg transition-colors text-white mx-auto"
|
|
||||||
>
|
|
||||||
<FiPlus /> Добавить первый узел
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-gray-900 rounded-xl shadow-lg p-6">
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
className="graph-container border border-gray-800 rounded-lg overflow-hidden relative"
|
|
||||||
style={{
|
|
||||||
height: "calc(100vh - 200px)",
|
|
||||||
position: "relative",
|
|
||||||
width: "100%",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ForceGraph2D
|
|
||||||
ref={fgRef}
|
|
||||||
graphData={data}
|
|
||||||
width={dimensions.width}
|
|
||||||
height={dimensions.height}
|
|
||||||
nodeCanvasObject={renderNode}
|
|
||||||
nodeLabel={(node: GraphNode) => {
|
|
||||||
return `${node.name}\n${node.description || ""}\n${node.type === "service" ? "Сервис" : "Агент"}\nПКМ для удаления`;
|
|
||||||
}}
|
|
||||||
linkLabel={(link: GraphLink) => {
|
|
||||||
// ВОЗВРАЩАЕМ СТРОКУ
|
|
||||||
const sourceName =
|
|
||||||
data.nodes.find((n) => n.id === link.source)?.name || link.source;
|
|
||||||
const targetName =
|
|
||||||
data.nodes.find((n) => n.id === link.target)?.name || link.target;
|
|
||||||
return `Связь: ${sourceName} → ${targetName}\nПКМ для удаления`;
|
|
||||||
}}
|
|
||||||
linkColor={(link: any) => {
|
|
||||||
return highlightLinks.has(link) ? "#fbbf24" : "#4b5563";
|
|
||||||
}}
|
|
||||||
linkWidth={(link: any) => (highlightLinks.has(link) ? 3 : 1.5)}
|
|
||||||
linkDirectionalParticles={0}
|
|
||||||
onNodeClick={handleNodeClick}
|
|
||||||
onNodeRightClick={(node, event) =>
|
|
||||||
openContextMenu(event as any, node, undefined)
|
|
||||||
}
|
|
||||||
onLinkRightClick={(link, event) =>
|
|
||||||
openContextMenu(event as any, undefined, link)
|
|
||||||
}
|
|
||||||
onNodeHover={handleNodeHover}
|
|
||||||
cooldownTicks={50}
|
|
||||||
cooldownTime={2000}
|
|
||||||
d3AlphaDecay={0.03}
|
|
||||||
d3VelocityDecay={0.4}
|
|
||||||
warmupTicks={50}
|
|
||||||
onEngineStop={() => {
|
|
||||||
if (fgRef.current) {
|
|
||||||
setTimeout(() => {
|
|
||||||
if (fgRef.current) {
|
|
||||||
fgRef.current.zoomToFit(400);
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{contextMenu && (
|
|
||||||
<div
|
|
||||||
className="fixed bg-gray-800 rounded-lg shadow-lg border border-gray-700 py-1 z-50"
|
|
||||||
style={{ top: contextMenu.y, left: contextMenu.x }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{contextMenu.node && (
|
|
||||||
<>
|
|
||||||
<div className="px-3 py-1 text-xs text-gray-400 border-b border-gray-700">
|
|
||||||
{contextMenu.node.name}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsLinkMode(true);
|
|
||||||
setSelectedNode(contextMenu.node);
|
|
||||||
setContextMenu(null);
|
|
||||||
}}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-gray-300 hover:bg-gray-700 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<FiLink size={14} /> Создать связь
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteNode(contextMenu.node!)}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<FiTrash2 size={14} /> Удалить узел
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{contextMenu.link && (
|
|
||||||
<>
|
|
||||||
<div className="px-3 py-1 text-xs text-gray-400 border-b border-gray-700">
|
|
||||||
Связь:{" "}
|
|
||||||
{typeof contextMenu.link.source === "string"
|
|
||||||
? contextMenu.link.source
|
|
||||||
: (contextMenu.link.source as any).name ||
|
|
||||||
(contextMenu.link.source as any).id}{" "}
|
|
||||||
→{" "}
|
|
||||||
{typeof contextMenu.link.target === "string"
|
|
||||||
? contextMenu.link.target
|
|
||||||
: (contextMenu.link.target as any).name ||
|
|
||||||
(contextMenu.link.target as any).id}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteLink(contextMenu.link!)}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-gray-700 flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<FiMinusCircle size={14} /> Удалить связь
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLinkMode && (
|
|
||||||
<div className="absolute bottom-4 left-4 bg-green-600 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-2">
|
|
||||||
<FiLink /> Режим создания связей: кликните на два узла для
|
|
||||||
соединения
|
|
||||||
{selectedNode && (
|
|
||||||
<span className="ml-2">Выбран: {selectedNode.name}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 text-sm text-gray-500 flex justify-between items-center">
|
|
||||||
<div className="flex gap-6">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FiServer className="text-gray-400" />
|
|
||||||
<span>
|
|
||||||
Сервисы: {data.nodes.filter((n) => n.type === "service").length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FiCpu className="text-gray-400" />
|
|
||||||
<span>
|
|
||||||
Агенты: {data.nodes.filter((n) => n.type === "agent").length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-3 h-3 bg-gray-500 rounded-sm"></div>
|
|
||||||
<span>Связи: {data.links.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setIsLinkMode(!isLinkMode);
|
|
||||||
setSelectedNode(null);
|
|
||||||
}}
|
|
||||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg transition-colors ${
|
|
||||||
isLinkMode
|
|
||||||
? "bg-green-600 hover:bg-green-700 text-white"
|
|
||||||
: "bg-gray-800 hover:bg-gray-700 text-gray-300"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<FiLink />
|
|
||||||
<span className="text-sm">
|
|
||||||
{isLinkMode ? "Создание связи..." : "Добавить связь"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleAddNode}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
|
||||||
>
|
|
||||||
<FiPlus />
|
|
||||||
<span className="text-sm">Узел</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
|
||||||
>
|
|
||||||
<FiZoomIn />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
|
||||||
>
|
|
||||||
<FiZoomOut />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleFit}
|
|
||||||
className="p-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
|
||||||
>
|
|
||||||
<FiMove />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleExport}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 bg-gray-800 hover:bg-gray-700 rounded-lg transition-colors text-gray-300"
|
|
||||||
>
|
|
||||||
<FiDownload />
|
|
||||||
<span className="text-sm">Экспорт</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,17 +1,90 @@
|
|||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
||||||
import { Navigate, Outlet } from "react-router-dom";
|
import { ThemeToggle } from "@/modules/theme-bw/ui/ThemeToggle";
|
||||||
import { Layout } from "@/app/providers/layout/layout";
|
import React from "react";
|
||||||
|
import { Outlet, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export const DefaultLayout = () => {
|
interface DefaultLayoutProps {
|
||||||
const { token } = useAuthStore();
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
// if (!token) {
|
export const DefaultLayout: React.FC<DefaultLayoutProps> = ({ children }) => {
|
||||||
// return <Navigate to="/auth" replace />;
|
const { user, logout } = useAuthStore();
|
||||||
// }
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout();
|
||||||
|
navigate("/auth");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<div className="min-h-screen flex flex-col" style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}>
|
||||||
<Outlet />
|
{/* Header */}
|
||||||
</Layout>
|
<header
|
||||||
|
className="border-b sticky top-0 z-50"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--header-bg)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="container mx-auto px-4 py-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
{/* Logo */}
|
||||||
|
<div
|
||||||
|
className="text-xl font-bold cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
onClick={() => navigate("/")}
|
||||||
|
>
|
||||||
|
HellreigN
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<ThemeToggle />
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{user.firstName} {user.lastName}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg transition-colors font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--button-danger)",
|
||||||
|
color: "var(--button-danger-text)",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--button-danger-hover)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.backgroundColor = "var(--button-danger)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<main className="flex-1">{children || <Outlet />}</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer
|
||||||
|
className="border-t py-4 mt-auto"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<p className="text-center text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
© 2026 HellreigN. Все права защищены.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+510
-1332
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user