Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 514e3e30b6 | |||
| 94ff261c9a | |||
| a44630cfea | |||
| b69f2e4c9a | |||
| d96f952d73 | |||
| 27e82f80f1 | |||
| 83427193bc | |||
| 28ef2dc1fd | |||
| 2ebf374413 | |||
| 3293915062 | |||
| 8913353e64 | |||
| 9992e254d5 | |||
| b75d95f9a7 |
@@ -0,0 +1,26 @@
|
|||||||
|
FROM golang:1.26.1 as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
ENV CGO_ENABLED=0
|
||||||
|
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||||
|
--mount=type=cache,target=/root/.cache/go-build \
|
||||||
|
go build -ldflags "-s -w" -o agent ./cmd/main.go
|
||||||
|
|
||||||
|
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 /app/agent .
|
||||||
|
|
||||||
|
CMD ["./agent"]
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/agent
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
@@ -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,89 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/docs"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/config"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/handlers"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/repository"
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
swaggerFiles "github.com/swaggo/files"
|
||||||
|
ginSwagger "github.com/swaggo/gin-swagger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// @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")
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := storage.Open(cfg.Database.Token_db)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Err opening database")
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
h := handlers.New(db)
|
||||||
|
agents := handlers.AgentsGroup{Handlers: h}
|
||||||
|
|
||||||
|
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")
|
||||||
|
{
|
||||||
|
agentsGroup := v1.Group("/agents")
|
||||||
|
{
|
||||||
|
agentsGroup.GET("", agents.List)
|
||||||
|
}
|
||||||
|
|
||||||
|
logsGroup := v1.Group("/logs")
|
||||||
|
{
|
||||||
|
if cfg.Database.Clickhouse_host != "" {
|
||||||
|
chConn, err := storage.OpenClickHouse(storage.ClickHouseConfig{
|
||||||
|
Host: cfg.Database.Clickhouse_host,
|
||||||
|
User: cfg.Database.Clickhouse_user,
|
||||||
|
Password: cfg.Database.Clickhouse_password,
|
||||||
|
Database: cfg.Database.Clickhouse_database,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Warning: ClickHouse connection failed: %v", err)
|
||||||
|
} else {
|
||||||
|
defer chConn.Close()
|
||||||
|
|
||||||
|
logRepo := repository.NewLogRepository(chConn)
|
||||||
|
if err := logRepo.Init(context.Background()); err != nil {
|
||||||
|
log.Printf("Warning: Failed to initialize logs table: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Fatal(router.Run(":8080"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM golang:1.26.1 as builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
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
|
||||||
|
|
||||||
|
COPY --from=builder /app/backend .
|
||||||
|
COPY --from=builder /app/scripts /etc/mnemosyne/scripts
|
||||||
|
RUN chmod +x /etc/mnemosyne/scripts/generate-certs.sh
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "curl --fail http://localhost:8080/health" ]
|
||||||
|
|
||||||
|
CMD ["./backend"]
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Package docs Code generated by swaggo/swag. DO NOT EDIT
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import "github.com/swaggo/swag"
|
||||||
|
|
||||||
|
const docTemplate = `{
|
||||||
|
"schemes": {{ marshal .Schemes }},
|
||||||
|
"swagger": "2.0",
|
||||||
|
"info": {
|
||||||
|
"description": "{{escape .Description}}",
|
||||||
|
"title": "{{.Title}}",
|
||||||
|
"contact": {},
|
||||||
|
"version": "{{.Version}}"
|
||||||
|
},
|
||||||
|
"host": "{{.Host}}",
|
||||||
|
"basePath": "{{.BasePath}}",
|
||||||
|
"paths": {
|
||||||
|
"/agents": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a list of all agents currently connected via gRPC streaming",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"agents"
|
||||||
|
],
|
||||||
|
"summary": "Get connected agents",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/internal_handlers.AgentInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"internal_handlers.AgentInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"securityDefinitions": {
|
||||||
|
"Bearer": {
|
||||||
|
"description": "Type \"Bearer\" followed by a space and the JWT token.",
|
||||||
|
"type": "apiKey",
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
// SwaggerInfo holds exported Swagger Info so clients can modify it
|
||||||
|
var SwaggerInfo = &swag.Spec{
|
||||||
|
Version: "",
|
||||||
|
Host: "",
|
||||||
|
BasePath: "",
|
||||||
|
Schemes: []string{},
|
||||||
|
Title: "",
|
||||||
|
Description: "",
|
||||||
|
InfoInstanceName: "swagger",
|
||||||
|
SwaggerTemplate: docTemplate,
|
||||||
|
LeftDelim: "{{",
|
||||||
|
RightDelim: "}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"swagger": "2.0",
|
||||||
|
"info": {
|
||||||
|
"contact": {}
|
||||||
|
},
|
||||||
|
"paths": {
|
||||||
|
"/agents": {
|
||||||
|
"get": {
|
||||||
|
"description": "Returns a list of all agents currently connected via gRPC streaming",
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"agents"
|
||||||
|
],
|
||||||
|
"summary": "Get connected agents",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/internal_handlers.AgentInfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"internal_handlers.AgentInfo": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"label": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"services": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"securityDefinitions": {
|
||||||
|
"Bearer": {
|
||||||
|
"description": "Type \"Bearer\" followed by a space and the JWT token.",
|
||||||
|
"type": "apiKey",
|
||||||
|
"name": "Authorization",
|
||||||
|
"in": "header"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
definitions:
|
||||||
|
internal_handlers.AgentInfo:
|
||||||
|
properties:
|
||||||
|
label:
|
||||||
|
type: string
|
||||||
|
services:
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
type: array
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
info:
|
||||||
|
contact: {}
|
||||||
|
paths:
|
||||||
|
/agents:
|
||||||
|
get:
|
||||||
|
description: Returns a list of all agents currently connected via gRPC streaming
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/internal_handlers.AgentInfo'
|
||||||
|
type: array
|
||||||
|
summary: Get connected agents
|
||||||
|
tags:
|
||||||
|
- agents
|
||||||
|
securityDefinitions:
|
||||||
|
Bearer:
|
||||||
|
description: Type "Bearer" followed by a space and the JWT token.
|
||||||
|
in: header
|
||||||
|
name: Authorization
|
||||||
|
type: apiKey
|
||||||
|
swagger: "2.0"
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
module gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/ClickHouse/ch-go v0.71.0 // indirect
|
||||||
|
github.com/ClickHouse/clickhouse-go/v2 v2.44.0 // indirect
|
||||||
|
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||||
|
github.com/PuerkitoBio/purell v1.2.1 // indirect
|
||||||
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // 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/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // 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/gin-gonic/gin v1.12.0 // 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 v0.25.5 // 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/josharian/intern v1.0.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/mailru/easyjson v0.9.2 // 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/swaggo/files v1.0.1 // indirect
|
||||||
|
github.com/swaggo/gin-swagger v1.6.1 // indirect
|
||||||
|
github.com/swaggo/swag v1.16.6 // 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/crypto v0.49.0 // indirect
|
||||||
|
golang.org/x/mod v0.34.0 // indirect
|
||||||
|
golang.org/x/net v0.52.0 // indirect
|
||||||
|
golang.org/x/sync v0.20.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/protobuf v1.36.11 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.70.0 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.48.1 // indirect
|
||||||
|
)
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
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/PuerkitoBio/purell v1.2.1 h1:QsZ4TjvwiMpat6gBCBxEQI0rcS9ehtkKtSpiUnd9N28=
|
||||||
|
github.com/PuerkitoBio/purell v1.2.1/go.mod h1:ZwHcC/82TOaovDi//J/804umJFFmbOHPngi8iYYv/Eo=
|
||||||
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||||
|
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||||
|
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.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||||
|
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||||
|
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/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||||
|
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||||
|
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/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/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-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.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU=
|
||||||
|
github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA=
|
||||||
|
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/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-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/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
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/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
|
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||||
|
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.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
|
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/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
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/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/mailru/easyjson v0.9.2 h1:dX8U45hQsZpxd80nLvDGihsQ/OxlvTkVUXH2r/8cb2M=
|
||||||
|
github.com/mailru/easyjson v0.9.2/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
|
||||||
|
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/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/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.0/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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
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/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/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/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
|
||||||
|
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
|
||||||
|
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.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.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
|
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=
|
||||||
|
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/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
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/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA=
|
||||||
|
modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||||
@@ -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,26 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentsGroup struct {
|
||||||
|
*Handlers
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentInfo struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Services []string `json:"services"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// @Summary Get connected agents
|
||||||
|
// @Description Returns a list of all agents currently connected via gRPC streaming
|
||||||
|
// @Tags agents
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} AgentInfo
|
||||||
|
// @Router /agents [get]
|
||||||
|
func (ag *AgentsGroup) List(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "Agents list"})
|
||||||
|
}
|
||||||
@@ -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,229 @@
|
|||||||
|
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
|
||||||
|
// @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
|
||||||
|
// @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
|
||||||
|
// @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
|
||||||
|
// @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
|
||||||
|
// @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
|
||||||
|
// @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,178 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.d3m0k1d.ru/d3m0k1d/HellreigN/backend/internal/storage"
|
||||||
|
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogRepository struct {
|
||||||
|
Conn driver.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogRepository(conn driver.Conn) *LogRepository {
|
||||||
|
return &LogRepository{Conn: conn}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) Init(ctx context.Context) error {
|
||||||
|
return r.Conn.Exec(ctx, storage.CreateLogsTable)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) Insert(ctx context.Context, log storage.LogEntry) error {
|
||||||
|
return r.Conn.Exec(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LogRepository) InsertBatch(ctx context.Context, logs []storage.LogEntry) error {
|
||||||
|
batch, err := r.Conn.PrepareBatch(ctx, "INSERT INTO logs (timestamp, level, service, agent, message)")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, log := range logs {
|
||||||
|
if err := batch.Append(log.Timestamp, log.Level, log.Service, log.Agent, log.Message); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return batch.Send()
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
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 := r.Conn.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var logs []storage.LogEntry
|
||||||
|
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) {
|
||||||
|
rows, err := r.Conn.Query(ctx, "SELECT DISTINCT service FROM logs ORDER BY service")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var services []string
|
||||||
|
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) {
|
||||||
|
rows, err := r.Conn.Query(ctx, "SELECT DISTINCT agent FROM logs ORDER BY agent")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var agents []string
|
||||||
|
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) {
|
||||||
|
rows, err := r.Conn.Query(ctx, "SELECT DISTINCT level FROM logs ORDER BY level")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var levels []string
|
||||||
|
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,11 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import "database/sql"
|
||||||
|
|
||||||
|
type Repository struct {
|
||||||
|
DB *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *sql.DB) *Repository {
|
||||||
|
return &Repository{DB: db}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/ClickHouse/clickhouse-go/v2"
|
||||||
|
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClickHouseConfig struct {
|
||||||
|
Host string
|
||||||
|
User string
|
||||||
|
Password string
|
||||||
|
Database string
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenClickHouse(cfg ClickHouseConfig) (driver.Conn, error) {
|
||||||
|
conn, err := clickhouse.Open(&clickhouse.Options{
|
||||||
|
Addr: []string{cfg.Host},
|
||||||
|
Auth: clickhouse.Auth{
|
||||||
|
Database: cfg.Database,
|
||||||
|
Username: cfg.User,
|
||||||
|
Password: cfg.Password,
|
||||||
|
},
|
||||||
|
Settings: clickhouse.Settings{
|
||||||
|
"max_execution_time": 60,
|
||||||
|
},
|
||||||
|
Compression: &clickhouse.Compression{
|
||||||
|
Method: clickhouse.CompressionLZ4,
|
||||||
|
},
|
||||||
|
DialTimeout: 30,
|
||||||
|
MaxOpenConns: 10,
|
||||||
|
MaxIdleConns: 5,
|
||||||
|
ConnMaxLifetime: 3600,
|
||||||
|
ConnOpenStrategy: clickhouse.ConnOpenInOrder,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("clickhouse connect: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := conn.Ping(context.Background()); err != nil {
|
||||||
|
return nil, fmt.Errorf("clickhouse ping: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
@@ -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,27 @@
|
|||||||
|
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,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
permission_view BOOL NOT NULL,
|
||||||
|
permission_manage_agent BOOL NOT NULL,
|
||||||
|
permission_tokens BOOL NOT NULL
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
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,34 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package initial
|
||||||
|
|
||||||
|
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
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
.qwen
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(yarn *)",
|
|
||||||
"Bash(npx *)",
|
|
||||||
"Bash(npm run *)",
|
|
||||||
"Bash(type *)",
|
|
||||||
"Bash(dir)",
|
|
||||||
"Bash(move *)",
|
|
||||||
"Bash(findstr *)",
|
|
||||||
"Bash(del *)",
|
|
||||||
"Bash(mkdir *)"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"$version": 3
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -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;"]
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>frontend</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -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
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "HellreigN",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@codemirror/lang-sql": "^6.10.0",
|
|
||||||
"@monaco-editor/react": "^4.7.0",
|
|
||||||
"@tailwindcss/vite": "^4.2.2",
|
|
||||||
"@uiw/react-codemirror": "^4.25.8",
|
|
||||||
"axios": "^1.13.6",
|
|
||||||
"file-surf": "^1.0.3",
|
|
||||||
"framer-motion": "^12.38.0",
|
|
||||||
"monaco-languageclient": "^10.7.0",
|
|
||||||
"primeicons": "^7.0.0",
|
|
||||||
"primereact": "^10.9.7",
|
|
||||||
"react": "^19.2.4",
|
|
||||||
"react-dom": "^19.2.4",
|
|
||||||
"react-force-graph-2d": "^1.29.1",
|
|
||||||
"react-icons": "^5.6.0",
|
|
||||||
"react-router-dom": "^7.13.1",
|
|
||||||
"recharts": "^3.8.0",
|
|
||||||
"tailwind": "^4.0.0",
|
|
||||||
"tailwindcss": "^4.2.2",
|
|
||||||
"vscode-ws-jsonrpc": "^3.5.0",
|
|
||||||
"zustand": "^5.0.12"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.39.4",
|
|
||||||
"@types/node": "^24.12.0",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
|
||||||
"eslint": "^9.39.4",
|
|
||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
|
||||||
"globals": "^17.4.0",
|
|
||||||
"typescript": "~5.9.3",
|
|
||||||
"typescript-eslint": "^8.57.0",
|
|
||||||
"vite": "^8.0.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,29 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import "@/shared/styles/index.css";
|
|
||||||
import "primereact/resources/themes/lara-light-cyan/theme.css";
|
|
||||||
import "primereact/resources/primereact.min.css";
|
|
||||||
import "primeicons/primeicons.css";
|
|
||||||
import { PrimeReactProvider } from "primereact/api";
|
|
||||||
import { Routing } from "./providers/routing/routing";
|
|
||||||
import { AppLoader } from "./components/AppLoader";
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setTimeout(() => setLoading(false), 1800);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <AppLoader />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PrimeReactProvider>
|
|
||||||
<Routing />
|
|
||||||
</PrimeReactProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
@@ -1,247 +0,0 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { FaMicrochip, FaCode, FaNetworkWired, FaAtom } from "react-icons/fa";
|
|
||||||
|
|
||||||
export const AppLoader = () => {
|
|
||||||
const [progress, setProgress] = useState(0);
|
|
||||||
const [phase, setPhase] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const phases = [
|
|
||||||
{ progress: 25, delay: 400 },
|
|
||||||
{ progress: 50, delay: 300 },
|
|
||||||
{ progress: 75, delay: 400 },
|
|
||||||
{ progress: 100, delay: 300 },
|
|
||||||
];
|
|
||||||
|
|
||||||
let timeouts: NodeJS.Timeout[] = [];
|
|
||||||
let currentDelay = 0;
|
|
||||||
|
|
||||||
phases.forEach((p, i) => {
|
|
||||||
currentDelay += p.delay;
|
|
||||||
timeouts.push(
|
|
||||||
setTimeout(() => {
|
|
||||||
setProgress(p.progress);
|
|
||||||
setPhase(i);
|
|
||||||
}, currentDelay),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => timeouts.forEach(clearTimeout);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "#0a0a0f",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: 9999,
|
|
||||||
overflow: "hidden",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Background grid effect */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundImage: `
|
|
||||||
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
|
|
||||||
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px)
|
|
||||||
`,
|
|
||||||
backgroundSize: "40px 40px",
|
|
||||||
animation: "gridMove 20s linear infinite",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Glowing orbs */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: "300px",
|
|
||||||
height: "300px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
background:
|
|
||||||
"radial-gradient(circle, rgba(59,130,246,0.15) 0%, transparent 70%)",
|
|
||||||
filter: "blur(40px)",
|
|
||||||
animation: "orbFloat 6s ease-in-out infinite",
|
|
||||||
top: "20%",
|
|
||||||
left: "30%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
width: "250px",
|
|
||||||
height: "250px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
background:
|
|
||||||
"radial-gradient(circle, rgba(139,92,246,0.12) 0%, transparent 70%)",
|
|
||||||
filter: "blur(40px)",
|
|
||||||
animation: "orbFloat 8s ease-in-out infinite reverse",
|
|
||||||
bottom: "20%",
|
|
||||||
right: "30%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div style={{ position: "relative", zIndex: 1, textAlign: "center" }}>
|
|
||||||
{/* Logo with animation */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: "16px",
|
|
||||||
marginBottom: "40px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
animation: "logoSpin 3s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaAtom size={48} style={{ color: "#3b82f6" }} />
|
|
||||||
</div>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
fontSize: "42px",
|
|
||||||
fontWeight: 800,
|
|
||||||
background:
|
|
||||||
"linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
|
|
||||||
WebkitBackgroundClip: "text",
|
|
||||||
WebkitTextFillColor: "transparent",
|
|
||||||
letterSpacing: "4px",
|
|
||||||
animation: "titleGlow 2s ease-in-out infinite",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
HellreigN
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Loading icons animation */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: "24px",
|
|
||||||
marginBottom: "40px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{[
|
|
||||||
{ icon: FaMicrochip, delay: "0s" },
|
|
||||||
{ icon: FaNetworkWired, delay: "0.2s" },
|
|
||||||
{ icon: FaCode, delay: "0.4s" },
|
|
||||||
].map(({ icon: Icon, delay }, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
width: "50px",
|
|
||||||
height: "50px",
|
|
||||||
borderRadius: "12px",
|
|
||||||
border: `2px solid ${
|
|
||||||
phase >= i
|
|
||||||
? "rgba(59, 130, 246, 0.6)"
|
|
||||||
: "rgba(255,255,255,0.1)"
|
|
||||||
}`,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
backgroundColor:
|
|
||||||
phase >= i ? "rgba(59, 130, 246, 0.1)" : "transparent",
|
|
||||||
animation: `iconPop 0.5s ease-out ${delay} both`,
|
|
||||||
transition: "all 0.3s ease",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
size={22}
|
|
||||||
style={{
|
|
||||||
color: phase >= i ? "#3b82f6" : "#555",
|
|
||||||
transition: "color 0.3s ease",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Progress bar */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "320px",
|
|
||||||
height: "4px",
|
|
||||||
backgroundColor: "rgba(255,255,255,0.1)",
|
|
||||||
borderRadius: "2px",
|
|
||||||
overflow: "hidden",
|
|
||||||
marginBottom: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
width: `${progress}%`,
|
|
||||||
background:
|
|
||||||
"linear-gradient(90deg, #3b82f6 0%, #8b5cf6 50%, #06b6d4 100%)",
|
|
||||||
borderRadius: "2px",
|
|
||||||
transition: "width 0.4s ease",
|
|
||||||
boxShadow: "0 0 20px rgba(59, 130, 246, 0.5)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status text */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "13px",
|
|
||||||
color: "rgba(255,255,255,0.5)",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
letterSpacing: "2px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{phase === 0 && "INITIALIZING CORE..."}
|
|
||||||
{phase === 1 && "LOADING AGENTS..."}
|
|
||||||
{phase === 2 && "ESTABLISHING CONNECTIONS..."}
|
|
||||||
{phase === 3 && "READY"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CSS Animations */}
|
|
||||||
<style>{`
|
|
||||||
@keyframes gridMove {
|
|
||||||
0% { transform: translate(0, 0); }
|
|
||||||
100% { transform: translate(40px, 40px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes orbFloat {
|
|
||||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
|
||||||
50% { transform: translate(30px, -30px) scale(1.1); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes logoSpin {
|
|
||||||
0%, 100% { transform: rotate(0deg) scale(1); }
|
|
||||||
25% { transform: rotate(-10deg) scale(1.05); }
|
|
||||||
75% { transform: rotate(10deg) scale(1.05); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes titleGlow {
|
|
||||||
0%, 100% { filter: brightness(1); }
|
|
||||||
50% { filter: brightness(1.3); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes iconPop {
|
|
||||||
0% { transform: scale(0.5) translateY(10px); opacity: 0; }
|
|
||||||
100% { transform: scale(1) translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useState, useEffect, type ReactNode } from "react";
|
|
||||||
import { Sidebar } from "@/app/providers/layout/sidebar/sidebar";
|
|
||||||
import {
|
|
||||||
Navigation,
|
|
||||||
BottomNav,
|
|
||||||
} from "@/app/providers/layout/navigation/navigation";
|
|
||||||
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
|
||||||
|
|
||||||
export const Layout = ({ children }: { children: ReactNode }) => {
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
|
||||||
const [isMobile, setIsMobile] = useState(() =>
|
|
||||||
typeof window !== "undefined" ? window.innerWidth < 856 : false,
|
|
||||||
);
|
|
||||||
const [isVerySmall, setIsVerySmall] = useState(() =>
|
|
||||||
typeof window !== "undefined" ? window.innerWidth < 600 : false,
|
|
||||||
);
|
|
||||||
const { fetchAgents } = useAgentStore();
|
|
||||||
|
|
||||||
const sidebarOpen = isMobile ? mobileOpen : true;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleResize = () => {
|
|
||||||
const mobile = window.innerWidth < 856;
|
|
||||||
setIsMobile(mobile);
|
|
||||||
if (!mobile) {
|
|
||||||
setMobileOpen(false);
|
|
||||||
}
|
|
||||||
setIsVerySmall(window.innerWidth < 600);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("resize", handleResize);
|
|
||||||
handleResize();
|
|
||||||
return () => window.removeEventListener("resize", handleResize);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
|
||||||
if (isMobile) {
|
|
||||||
setMobileOpen((prev) => !prev);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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={sidebarOpen}
|
|
||||||
onToggle={toggleSidebar}
|
|
||||||
isMobile={isMobile}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
||||||
<Navigation
|
|
||||||
onToggleSidebar={toggleSidebar}
|
|
||||||
isMobile={isMobile}
|
|
||||||
isVerySmall={isVerySmall}
|
|
||||||
/>
|
|
||||||
<div className="flex-1 overflow-auto p-4">{children}</div>
|
|
||||||
{isVerySmall && <BottomNav />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
|
||||||
import { useNavigate, useLocation } from "react-router-dom";
|
|
||||||
import { FaBars, FaCode, FaChevronDown } from "react-icons/fa";
|
|
||||||
import {
|
|
||||||
FaHome,
|
|
||||||
FaServer,
|
|
||||||
FaUser,
|
|
||||||
FaUsers,
|
|
||||||
FaRocket,
|
|
||||||
FaKey,
|
|
||||||
FaFileAlt,
|
|
||||||
FaPalette,
|
|
||||||
FaSignOutAlt,
|
|
||||||
FaShieldAlt,
|
|
||||||
} from "react-icons/fa";
|
|
||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
|
||||||
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
|
|
||||||
import { themes } from "@/modules/theme-changer/config/theme.config";
|
|
||||||
import {
|
|
||||||
applyTheme,
|
|
||||||
getCurrentTheme,
|
|
||||||
} from "@/modules/theme-changer/utils/apply.theme";
|
|
||||||
|
|
||||||
interface NavigationProps {
|
|
||||||
onToggleSidebar?: () => void;
|
|
||||||
isMobile?: boolean;
|
|
||||||
isVerySmall?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Navigation: React.FC<NavigationProps> = ({
|
|
||||||
onToggleSidebar,
|
|
||||||
isMobile,
|
|
||||||
isVerySmall = false,
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const { user, logout } = useAuthStore();
|
|
||||||
const { setTheme } = useThemeStore();
|
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
||||||
const [themePickerOpen, setThemePickerOpen] = useState(false);
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const currentTheme = getCurrentTheme();
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
|
|
||||||
{
|
|
||||||
path: "/add-agents",
|
|
||||||
label: "Деплой",
|
|
||||||
icon: FaRocket,
|
|
||||||
requireManageAgent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/registration",
|
|
||||||
label: "Регистрация",
|
|
||||||
icon: FaKey,
|
|
||||||
requireManageAgent: true,
|
|
||||||
},
|
|
||||||
{ path: "/logs", label: "Логи", icon: FaFileAlt, requireView: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const isActive = (path: string) => location.pathname === path;
|
|
||||||
|
|
||||||
// Filter nav items based on user permissions
|
|
||||||
const filteredNavItems = navItems.filter((item) => {
|
|
||||||
if (item.requireView && !user?.permission_view) return false;
|
|
||||||
if (item.requireManageAgent && !user?.permission_manage_agent) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
|
||||||
if (
|
|
||||||
dropdownRef.current &&
|
|
||||||
!dropdownRef.current.contains(e.target as Node)
|
|
||||||
) {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
setThemePickerOpen(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleLogout = () => {
|
|
||||||
logout();
|
|
||||||
navigate("/auth");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleThemeChange = (themeId: string) => {
|
|
||||||
applyTheme(themeId);
|
|
||||||
setTheme(themeId as any);
|
|
||||||
setThemePickerOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderNavItems = (showLabels: boolean, iconSize: number) => (
|
|
||||||
<div className="flex items-center gap-1 whitespace-nowrap">
|
|
||||||
{filteredNavItems.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-2 rounded-lg font-medium transition-all flex-shrink-0"
|
|
||||||
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)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title={item.label}
|
|
||||||
>
|
|
||||||
<Icon size={iconSize} />
|
|
||||||
{showLabels && <span className="text-xs">{item.label}</span>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
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">
|
|
||||||
{/* Бургер — только на мобильных */}
|
|
||||||
{isMobile && (
|
|
||||||
<button
|
|
||||||
onClick={onToggleSidebar}
|
|
||||||
className="p-1.5 mr-2 rounded-lg transition-colors flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
aria-label="Открыть sidebar"
|
|
||||||
>
|
|
||||||
<FaBars size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Название по центру — только на очень маленьких экранах */}
|
|
||||||
{isVerySmall && (
|
|
||||||
<div className="flex-1 text-center mx-4">
|
|
||||||
<span
|
|
||||||
className="text-sm font-bold"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
HellreigN
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Навигация — только если НЕ очень маленький экран */}
|
|
||||||
{!isVerySmall && (
|
|
||||||
<div className="flex items-center flex-1 mx-4 overflow-x-auto scrollbar-hide">
|
|
||||||
{renderNavItems(true, 12)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Профиль пользователя — дропдаун */}
|
|
||||||
<div className="relative" ref={dropdownRef}>
|
|
||||||
<button
|
|
||||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: dropdownOpen
|
|
||||||
? "var(--bg-secondary)"
|
|
||||||
: "transparent",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-7 h-7 rounded-full flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
<FaUser size={11} style={{ color: "var(--accent-text)" }} />
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className="text-xs font-medium"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
{user?.name || user?.login || "Пользователь"}
|
|
||||||
</span>
|
|
||||||
<FaChevronDown
|
|
||||||
size={10}
|
|
||||||
style={{
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
transform: dropdownOpen ? "rotate(180deg)" : "rotate(0)",
|
|
||||||
transition: "transform 0.2s",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{dropdownOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute right-0 top-full mt-2 rounded-lg shadow-xl border z-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
minWidth: "220px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="px-4 py-3 border-b"
|
|
||||||
style={{ borderColor: "var(--border)" }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<div
|
|
||||||
className="w-8 h-8 rounded-full flex items-center justify-center"
|
|
||||||
style={{ backgroundColor: "var(--accent)" }}
|
|
||||||
>
|
|
||||||
<FaUser
|
|
||||||
size={12}
|
|
||||||
style={{ color: "var(--accent-text)" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
className="text-sm font-semibold"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
>
|
|
||||||
{user?.name || user?.login}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className="text-[10px]"
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{user?.login}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setThemePickerOpen(!themePickerOpen)}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor =
|
|
||||||
"var(--bg-secondary)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaPalette
|
|
||||||
size={12}
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
/>
|
|
||||||
<span className="flex-1 text-left">
|
|
||||||
Тема: {themes.find((t) => t.id === currentTheme)?.name}
|
|
||||||
</span>
|
|
||||||
<FaChevronDown
|
|
||||||
size={9}
|
|
||||||
style={{
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
transform: themePickerOpen
|
|
||||||
? "rotate(180deg)"
|
|
||||||
: "rotate(0)",
|
|
||||||
transition: "transform 0.2s",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{themePickerOpen && (
|
|
||||||
<div
|
|
||||||
className="absolute right-full top-0 mr-1 rounded-lg shadow-xl border z-50"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
minWidth: "180px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{themes.map((t) => (
|
|
||||||
<button
|
|
||||||
key={t.id}
|
|
||||||
onClick={() => handleThemeChange(t.id)}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-2 text-xs transition-colors first:rounded-t-lg last:rounded-b-lg"
|
|
||||||
style={{
|
|
||||||
color:
|
|
||||||
currentTheme === t.id
|
|
||||||
? "var(--accent)"
|
|
||||||
: "var(--text-primary)",
|
|
||||||
backgroundColor:
|
|
||||||
currentTheme === t.id
|
|
||||||
? "var(--bg-secondary)"
|
|
||||||
: "transparent",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (currentTheme !== t.id) {
|
|
||||||
e.currentTarget.style.backgroundColor =
|
|
||||||
"var(--bg-secondary)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (currentTheme !== t.id) {
|
|
||||||
e.currentTarget.style.backgroundColor =
|
|
||||||
"transparent";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-4 h-4 rounded-full border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: t.colors.primary,
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span>{t.name}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{user?.permission_admin && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setDropdownOpen(false);
|
|
||||||
navigate("/admin");
|
|
||||||
}}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor =
|
|
||||||
"var(--bg-secondary)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaShieldAlt size={12} style={{ color: "#f59e0b" }} />
|
|
||||||
<span>Админка</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="my-1 border-b"
|
|
||||||
style={{ borderColor: "var(--border)" }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleLogout}
|
|
||||||
className="w-full flex items-center gap-3 px-4 py-2.5 text-xs transition-colors rounded-b-lg"
|
|
||||||
style={{ color: "var(--error-text)" }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor =
|
|
||||||
"rgba(239, 68, 68, 0.1)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaSignOutAlt size={12} />
|
|
||||||
<span>Выйти</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BottomNav: React.FC = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const location = useLocation();
|
|
||||||
const { user } = useAuthStore();
|
|
||||||
|
|
||||||
const navItems = [
|
|
||||||
{ path: "/templates", label: "Шаблоны", icon: FaCode, requireView: true },
|
|
||||||
{
|
|
||||||
path: "/add-agents",
|
|
||||||
label: "Деплой",
|
|
||||||
icon: FaRocket,
|
|
||||||
requireManageAgent: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "/registration",
|
|
||||||
label: "Регистрация",
|
|
||||||
icon: FaKey,
|
|
||||||
requireManageAgent: true,
|
|
||||||
},
|
|
||||||
{ path: "/logs", label: "Логи", icon: FaFileAlt, requireView: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
const isActive = (path: string) => location.pathname === path;
|
|
||||||
|
|
||||||
// Filter nav items based on user permissions
|
|
||||||
const filteredNavItems = navItems.filter((item) => {
|
|
||||||
if (item.requireView && !user?.permission_view) return false;
|
|
||||||
if (item.requireManageAgent && !user?.permission_manage_agent) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex-shrink-0 border-t"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-around px-2 py-2">
|
|
||||||
{filteredNavItems.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
const active = isActive(item.path);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={item.path}
|
|
||||||
onClick={() => navigate(item.path)}
|
|
||||||
className="flex items-center justify-center p-3 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
backgroundColor: active ? "var(--accent)" : "transparent",
|
|
||||||
color: active ? "var(--accent-text)" : "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
title={item.label}
|
|
||||||
>
|
|
||||||
<Icon size={20} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,717 +0,0 @@
|
|||||||
import React, { useMemo, useState, useRef, useEffect } from "react";
|
|
||||||
import {
|
|
||||||
FaBars,
|
|
||||||
FaMicrochip,
|
|
||||||
FaTimes,
|
|
||||||
FaSpinner,
|
|
||||||
FaCopy,
|
|
||||||
FaCheck,
|
|
||||||
FaChevronRight,
|
|
||||||
FaChevronDown,
|
|
||||||
FaProjectDiagram,
|
|
||||||
FaTrash,
|
|
||||||
FaArrowLeft,
|
|
||||||
} from "react-icons/fa";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
|
||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
|
||||||
import { Graph, type GraphData } from "@/modules/graph";
|
|
||||||
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
|
||||||
import { adminApi } from "@/modules/admin/api/admin.api";
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
isOpen?: boolean;
|
|
||||||
onToggle?: () => void;
|
|
||||||
isMobile?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({
|
|
||||||
isOpen = true,
|
|
||||||
onToggle,
|
|
||||||
isMobile = false,
|
|
||||||
}) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { agents, isLoading, error, fetchAgents, removeAgent } =
|
|
||||||
useAgentStore();
|
|
||||||
const { token } = useAuthStore();
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [copied, setCopied] = useState(false);
|
|
||||||
const [showTokenModal, setShowTokenModal] = useState(false);
|
|
||||||
const [showGraphs, setShowGraphs] = useState(false);
|
|
||||||
const [sidebarWidth, setSidebarWidth] = useState(288);
|
|
||||||
const sidebarRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [expandedAgents, setExpandedAgents] = useState<Set<string>>(
|
|
||||||
new Set(agents.map((a) => a.label)),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Рассчитываем максимальную ширину при переключении на графы
|
|
||||||
useEffect(() => {
|
|
||||||
const updateWidth = () => {
|
|
||||||
const targetWidth = showGraphs ? 500 : 288;
|
|
||||||
const maxWidth = window.innerWidth - 200;
|
|
||||||
const finalWidth = Math.min(targetWidth, maxWidth);
|
|
||||||
setSidebarWidth(Math.max(finalWidth, 250));
|
|
||||||
};
|
|
||||||
|
|
||||||
updateWidth();
|
|
||||||
window.addEventListener("resize", updateWidth);
|
|
||||||
return () => window.removeEventListener("resize", updateWidth);
|
|
||||||
}, [showGraphs]);
|
|
||||||
|
|
||||||
// Token generation state
|
|
||||||
const [tokenLabel, setTokenLabel] = useState("");
|
|
||||||
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
|
||||||
const [tokenGenerating, setTokenGenerating] = useState(false);
|
|
||||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const toggleAgent = (label: string) => {
|
|
||||||
setExpandedAgents((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(label)) next.delete(label);
|
|
||||||
else next.add(label);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredAgents = useMemo(() => {
|
|
||||||
if (!searchQuery) return agents;
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
return agents.filter(
|
|
||||||
(agent) =>
|
|
||||||
agent.label.toLowerCase().includes(query) ||
|
|
||||||
agent.services.some((s) => s.toLowerCase().includes(query)),
|
|
||||||
);
|
|
||||||
}, [agents, searchQuery]);
|
|
||||||
|
|
||||||
const [graphData, setGraphData] = useState<GraphData>({
|
|
||||||
nodes: [],
|
|
||||||
links: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchGraph = () => {
|
|
||||||
agentApiService
|
|
||||||
.getGraph()
|
|
||||||
.then((apiData) => {
|
|
||||||
const nodes: any[] = [];
|
|
||||||
const links: any[] = [];
|
|
||||||
|
|
||||||
// Build a map of service statuses from agents
|
|
||||||
const serviceStatusMap = new Map<string, "up" | "down">();
|
|
||||||
agents.forEach((agent) => {
|
|
||||||
const services = agent.services || [];
|
|
||||||
services.forEach((svc: string) => {
|
|
||||||
const parts = svc.split(":");
|
|
||||||
const svcName = parts[0];
|
|
||||||
const status = parts[1] === "down" ? "down" : "up";
|
|
||||||
serviceStatusMap.set(`${agent.label}-${svcName}`, status);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.entries(apiData.nodes || {}).forEach(
|
|
||||||
([agentLabel, agentNode]: [string, any]) => {
|
|
||||||
nodes.push({
|
|
||||||
id: agentLabel,
|
|
||||||
name: agentLabel,
|
|
||||||
type: "agent" as const,
|
|
||||||
val: 8,
|
|
||||||
description: `Агент: ${agentLabel}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const services = agentNode?.services || {};
|
|
||||||
Object.entries(services).forEach(
|
|
||||||
([serviceName, serviceNode]: [string, any]) => {
|
|
||||||
const serviceId = `${agentLabel}-${serviceName}`;
|
|
||||||
const status = serviceStatusMap.get(serviceId) || "up";
|
|
||||||
|
|
||||||
nodes.push({
|
|
||||||
id: serviceId,
|
|
||||||
name: serviceName,
|
|
||||||
type: "service" as const,
|
|
||||||
val: 12,
|
|
||||||
description: `Сервис: ${serviceName}`,
|
|
||||||
status,
|
|
||||||
});
|
|
||||||
|
|
||||||
links.push({
|
|
||||||
source: agentLabel,
|
|
||||||
target: serviceId,
|
|
||||||
type: "hosts",
|
|
||||||
});
|
|
||||||
|
|
||||||
const dependencies = serviceNode?.dependencies || [];
|
|
||||||
dependencies.forEach((dep: any) => {
|
|
||||||
const targetName = dep?.target?.name;
|
|
||||||
if (targetName) {
|
|
||||||
links.push({
|
|
||||||
source: serviceId,
|
|
||||||
target: `${agentLabel}-${targetName}`,
|
|
||||||
type: dep.condition || "dependency",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
setGraphData({ nodes, links });
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
console.error("Failed to fetch graph:", e);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchGraph();
|
|
||||||
const interval = setInterval(fetchGraph, 30000);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [agents]);
|
|
||||||
|
|
||||||
const handleCopyToken = () => {
|
|
||||||
const tokenToCopy = generatedToken || token;
|
|
||||||
if (tokenToCopy) {
|
|
||||||
navigator.clipboard.writeText(tokenToCopy);
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerateToken = async () => {
|
|
||||||
if (!tokenLabel.trim()) return;
|
|
||||||
setTokenGenerating(true);
|
|
||||||
setTokenError(null);
|
|
||||||
try {
|
|
||||||
const newToken = await adminApi.generateToken(tokenLabel.trim());
|
|
||||||
setGeneratedToken(newToken);
|
|
||||||
} catch (e) {
|
|
||||||
setTokenError(
|
|
||||||
e instanceof Error ? e.message : "Failed to generate token",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setTokenGenerating(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCloseTokenModal = () => {
|
|
||||||
setShowTokenModal(false);
|
|
||||||
setTokenLabel("");
|
|
||||||
setGeneratedToken(null);
|
|
||||||
setTokenError(null);
|
|
||||||
setCopied(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Overlay — только на мобильных (< 856px) */}
|
|
||||||
{isMobile && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 z-40" onClick={onToggle} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<aside
|
|
||||||
ref={sidebarRef}
|
|
||||||
className={`${isMobile ? "fixed" : "relative"} z-50 transition-all duration-300 ease-in-out flex flex-col`}
|
|
||||||
style={{
|
|
||||||
width: `${sidebarWidth}px`,
|
|
||||||
height: "100vh",
|
|
||||||
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 ${isMobile ? "" : "hidden"}`}
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
aria-label="Закрыть sidebar"
|
|
||||||
>
|
|
||||||
<FaTimes size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Контент — либо список агентов, либо графы */}
|
|
||||||
{showGraphs ? (
|
|
||||||
<div className="flex-1 overflow-hidden relative">
|
|
||||||
<Graph initialData={graphData} />
|
|
||||||
</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) => {
|
|
||||||
const isExpanded = expandedAgents.has(agent.label);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={agent.label}
|
|
||||||
className="rounded-lg border overflow-hidden transition-all group"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Agent header — кликабельный для сворачивания */}
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:opacity-80 transition-opacity"
|
|
||||||
onClick={() => toggleAgent(agent.label)}
|
|
||||||
>
|
|
||||||
<span style={{ color: "var(--text-muted)" }}>
|
|
||||||
{isExpanded ? (
|
|
||||||
<FaChevronDown size={10} />
|
|
||||||
) : (
|
|
||||||
<FaChevronRight size={10} />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<FaMicrochip
|
|
||||||
size={12}
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="text-sm font-medium flex-1 truncate cursor-pointer"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(`/dashboard/${agent.label}`);
|
|
||||||
}}
|
|
||||||
title="Открыть дашборд агента"
|
|
||||||
>
|
|
||||||
{agent.label}
|
|
||||||
</span>
|
|
||||||
{/* Статус-индикатор агента (количество сервисов) */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{agent.services.length > 0 && (
|
|
||||||
<span
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: "#4ade80" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
className="text-[10px]"
|
|
||||||
style={{ color: "var(--text-muted)" }}
|
|
||||||
>
|
|
||||||
{agent.services.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Кнопка удаления — появляется при наведении */}
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (
|
|
||||||
window.confirm(
|
|
||||||
`Удалить агента "${agent.label}"?`,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
removeAgent(agent.label);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="opacity-0 group-hover:opacity-100 p-1 rounded transition-all flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.color = "#f87171";
|
|
||||||
e.currentTarget.style.backgroundColor =
|
|
||||||
"rgba(248, 113, 113, 0.15)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.color = "var(--text-muted)";
|
|
||||||
e.currentTarget.style.backgroundColor =
|
|
||||||
"transparent";
|
|
||||||
}}
|
|
||||||
title="Удалить агента"
|
|
||||||
>
|
|
||||||
<FaTrash size={10} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Services list — сворачивается */}
|
|
||||||
{isExpanded && (
|
|
||||||
<div
|
|
||||||
className="px-3 pb-2"
|
|
||||||
style={{ paddingLeft: "24px" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="border-l-2 pl-3 space-y-1"
|
|
||||||
style={{ borderColor: "var(--border)" }}
|
|
||||||
>
|
|
||||||
{agent.services.map((service) => {
|
|
||||||
// Parse "serviceName:up" or "serviceName:down"
|
|
||||||
const parts = service.split(":");
|
|
||||||
const serviceName = parts[0];
|
|
||||||
const isDown = parts[1] === "down";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={service}
|
|
||||||
className="flex items-center justify-between py-1"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="text-xs"
|
|
||||||
style={{
|
|
||||||
color: isDown
|
|
||||||
? "#ef4444"
|
|
||||||
: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{serviceName}
|
|
||||||
</span>
|
|
||||||
{/* Status indicator */}
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span
|
|
||||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
backgroundColor: isDown
|
|
||||||
? "#ef4444"
|
|
||||||
: "#4ade80",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="text-[10px] font-medium"
|
|
||||||
style={{
|
|
||||||
color: isDown ? "#ef4444" : "#4ade80",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isDown ? "down" : "run"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer с кнопками */}
|
|
||||||
<div
|
|
||||||
className="p-2 border-t flex gap-2"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showGraphs ? (
|
|
||||||
/* Кнопка назад к агентам */
|
|
||||||
<button
|
|
||||||
onClick={() => setShowGraphs(false)}
|
|
||||||
className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "var(--border)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaArrowLeft size={10} />К агентам
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
/* Кнопка Графы */
|
|
||||||
<button
|
|
||||||
onClick={() => setShowGraphs(true)}
|
|
||||||
className="flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs rounded transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "var(--border)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaProjectDiagram size={10} />
|
|
||||||
Графы
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<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={handleCloseTokenModal}
|
|
||||||
>
|
|
||||||
<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={handleCloseTokenModal}
|
|
||||||
className="p-1 rounded transition-colors"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
<FaTimes size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-4 space-y-3">
|
|
||||||
{/* Error */}
|
|
||||||
{tokenError && (
|
|
||||||
<div
|
|
||||||
className="text-xs p-2 rounded"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "rgba(239,68,68,0.1)",
|
|
||||||
border: "1px solid rgba(239,68,68,0.3)",
|
|
||||||
color: "var(--error-text, #ef4444)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tokenError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Label input */}
|
|
||||||
{!generatedToken && (
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
className="block text-xs font-medium mb-2"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
Имя токена
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tokenLabel}
|
|
||||||
onChange={(e) => setTokenLabel(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter" && tokenLabel.trim()) {
|
|
||||||
handleGenerateToken();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Введите имя..."
|
|
||||||
autoFocus
|
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Generated token */}
|
|
||||||
{generatedToken && (
|
|
||||||
<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)" }}
|
|
||||||
>
|
|
||||||
{generatedToken}
|
|
||||||
</code>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{generatedToken && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setGeneratedToken(null);
|
|
||||||
setTokenLabel("");
|
|
||||||
}}
|
|
||||||
className="flex-1 py-2 rounded-lg text-xs font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Новый токен
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={
|
|
||||||
generatedToken ? handleCloseTokenModal : handleGenerateToken
|
|
||||||
}
|
|
||||||
disabled={tokenGenerating || !tokenLabel.trim()}
|
|
||||||
className="flex-1 py-2 rounded-lg text-xs font-medium transition-colors"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
|
||||||
? "var(--bg-secondary)"
|
|
||||||
: "var(--accent)",
|
|
||||||
color:
|
|
||||||
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
|
||||||
? "var(--text-muted)"
|
|
||||||
: "var(--accent-text)",
|
|
||||||
cursor:
|
|
||||||
tokenGenerating || (!generatedToken && !tokenLabel.trim())
|
|
||||||
? "default"
|
|
||||||
: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tokenGenerating
|
|
||||||
? "Генерация..."
|
|
||||||
: generatedToken
|
|
||||||
? "Готово"
|
|
||||||
: "Создать"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,35 +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.label !== name) });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { agentApiService } from "@/modules/agent/api/agent.api.service";
|
|
||||||
import type { SystemMetrics } from "@/modules/agent/types/agent.types";
|
|
||||||
|
|
||||||
interface MetricsState {
|
|
||||||
metrics: SystemMetrics[];
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
lastUpdated: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const POLLING_INTERVAL = 30_000;
|
|
||||||
|
|
||||||
let _pollingTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
export const useMetricsStore = create<MetricsState>(() => ({
|
|
||||||
metrics: [],
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
lastUpdated: null,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export const startMetricsPolling = async () => {
|
|
||||||
if (_pollingTimer) return;
|
|
||||||
const fetchMetrics = async () => {
|
|
||||||
try {
|
|
||||||
const data = await agentApiService.getSystemMetrics();
|
|
||||||
useMetricsStore.setState({
|
|
||||||
metrics: data,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
lastUpdated: Date.now(),
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
useMetricsStore.setState({
|
|
||||||
error: e instanceof Error ? e.message : "Failed to fetch metrics",
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
await fetchMetrics();
|
|
||||||
_pollingTimer = setInterval(fetchMetrics, POLLING_INTERVAL);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const stopMetricsPolling = () => {
|
|
||||||
if (_pollingTimer) {
|
|
||||||
clearInterval(_pollingTimer);
|
|
||||||
_pollingTimer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Navigate } from "react-router-dom";
|
|
||||||
import { useAuthStore } from "@/modules/auth/store/useAuthStore";
|
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
requireView?: boolean;
|
|
||||||
requireManageAgent?: boolean;
|
|
||||||
requireAdmin?: boolean;
|
|
||||||
fallbackPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
|
||||||
children,
|
|
||||||
requireView = false,
|
|
||||||
requireManageAgent = false,
|
|
||||||
requireAdmin = false,
|
|
||||||
fallbackPath = "/",
|
|
||||||
}) => {
|
|
||||||
const { user, isAuthenticated } = useAuthStore();
|
|
||||||
|
|
||||||
if (!isAuthenticated && user?.token) {
|
|
||||||
// User is authenticated based on token
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return <Navigate to="/auth" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requireView && !user.permission_view) {
|
|
||||||
return <Navigate to={fallbackPath} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requireManageAgent && !user.permission_manage_agent) {
|
|
||||||
return <Navigate to={fallbackPath} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (requireAdmin && !user.permission_admin) {
|
|
||||||
return <Navigate to={fallbackPath} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
@@ -1,213 +0,0 @@
|
|||||||
import { Suspense } from "react";
|
|
||||||
import { Routes as ReactRoutes, Route, Navigate } from "react-router-dom";
|
|
||||||
import { HomePage } from "@/pages/home.page";
|
|
||||||
import { TestPage } from "@/pages/test.page";
|
|
||||||
import { Graph, type GraphData } from "@/modules/graph";
|
|
||||||
import { AuthPage } from "@/pages/auth.page";
|
|
||||||
import { RegisterPage } from "@/pages/register.page";
|
|
||||||
import { DefaultLayout } from "@/shared/layouts/DefaultLayout";
|
|
||||||
import { AddAgentsPage } from "@/pages/add-agents.page";
|
|
||||||
import { IDEPage } from "@/pages/ide.page";
|
|
||||||
import { TemplatesPage } from "@/pages/templates.page";
|
|
||||||
import { AdminPage } from "@/pages/admin.page";
|
|
||||||
import { RegistrationTokenPage } from "@/pages/registration.page";
|
|
||||||
import { LogsPage } from "@/pages/logs.page";
|
|
||||||
import { GraphsPage } from "@/pages/graphs.page";
|
|
||||||
import { DashboardPage } from "@/pages/dashboard.page";
|
|
||||||
import { AgentDashboardPage } from "@/pages/agent-dashboard.page";
|
|
||||||
import { ProtectedRoute } from "./helper/protected.route";
|
|
||||||
|
|
||||||
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 = () => {
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
|
||||||
Загрузка...
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ReactRoutes>
|
|
||||||
<Route path="/auth" element={<AuthPage />} />
|
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
|
||||||
|
|
||||||
<Route element={<DefaultLayout />}>
|
|
||||||
{/* Routes requiring 'view' permission */}
|
|
||||||
<Route
|
|
||||||
path="/"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireView>
|
|
||||||
<TemplatesPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/logs"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireView>
|
|
||||||
<LogsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/graphs"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireView>
|
|
||||||
<GraphsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/dashboard/:agentLabel"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireView>
|
|
||||||
<AgentDashboardPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Routes requiring 'manage_agent' permission */}
|
|
||||||
<Route
|
|
||||||
path="/add-agents"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireManageAgent>
|
|
||||||
<AddAgentsPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/registration"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireManageAgent>
|
|
||||||
<RegistrationTokenPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/templates"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireView>
|
|
||||||
<TemplatesPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
|
||||||
path="/IDE"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireView>
|
|
||||||
<IDEPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Admin route requiring 'admin' permission */}
|
|
||||||
<Route
|
|
||||||
path="/admin"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requireAdmin>
|
|
||||||
<AdminPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Route>
|
|
||||||
|
|
||||||
<Route path="/test" element={<TestPage />} />
|
|
||||||
|
|
||||||
<Route path="/test2" element={<Graph initialData={mockGraphData} />} />
|
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
|
||||||
</ReactRoutes>
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { BrowserRouter } from "react-router";
|
|
||||||
import { ThemeInitialProvider } from "./modules/theme-changer";
|
|
||||||
import App from "./app/App";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<BrowserRouter>
|
|
||||||
<ThemeInitialProvider>
|
|
||||||
<App />
|
|
||||||
</ThemeInitialProvider>
|
|
||||||
</BrowserRouter>,
|
|
||||||
);
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
FaUsers,
|
|
||||||
FaShieldAlt,
|
|
||||||
FaSpinner,
|
|
||||||
FaExclamationCircle,
|
|
||||||
FaPlus,
|
|
||||||
} from "react-icons/fa";
|
|
||||||
import { useAdminStore } from "./store/useAdminStore";
|
|
||||||
import { UserCard } from "./components/UserCard";
|
|
||||||
import { CreateUserModal } from "./components/CreateUserModal";
|
|
||||||
|
|
||||||
export const AdminPanel: React.FC = () => {
|
|
||||||
const users = useAdminStore((s) => s.users);
|
|
||||||
const loading = useAdminStore((s) => s.loading);
|
|
||||||
const error = useAdminStore((s) => s.error);
|
|
||||||
const fetchUsers = useAdminStore((s) => s.fetchUsers);
|
|
||||||
|
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchUsers();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const activeCount = users.filter((u) => u.is_active).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: "24px", maxWidth: "900px", margin: "0 auto" }}>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginBottom: "24px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "40px",
|
|
||||||
height: "40px",
|
|
||||||
borderRadius: "8px",
|
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaShieldAlt size={18} style={{ color: "var(--accent-text)" }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1
|
|
||||||
style={{
|
|
||||||
fontSize: "18px",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
margin: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Управление пользователями
|
|
||||||
</h1>
|
|
||||||
<span style={{ fontSize: "12px", color: "var(--text-secondary)" }}>
|
|
||||||
{loading
|
|
||||||
? "Загрузка..."
|
|
||||||
: `${activeCount} / ${users.length} активных`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateModal(true)}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
padding: "8px 16px",
|
|
||||||
backgroundColor: "var(--accent)",
|
|
||||||
color: "var(--accent-text)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaPlus size={12} />
|
|
||||||
Добавить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "8px",
|
|
||||||
padding: "12px",
|
|
||||||
backgroundColor: "rgba(239,68,68,0.1)",
|
|
||||||
border: "1px solid rgba(239,68,68,0.3)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
color: "var(--error-text, #ef4444)",
|
|
||||||
marginBottom: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaExclamationCircle />
|
|
||||||
<span style={{ fontSize: "13px" }}>{error}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Loading */}
|
|
||||||
{loading && users.length === 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "60px 0",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaSpinner
|
|
||||||
className="animate-spin"
|
|
||||||
size={24}
|
|
||||||
style={{ color: "var(--accent)" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Users list */}
|
|
||||||
{!loading && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{users.map((user) => (
|
|
||||||
<UserCard key={user.id} user={user} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state */}
|
|
||||||
{!loading && users.length === 0 && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
textAlign: "center",
|
|
||||||
padding: "40px 0",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p style={{ fontSize: "14px" }}>
|
|
||||||
Нет зарегистрированных пользователей
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create user modal */}
|
|
||||||
<CreateUserModal
|
|
||||||
isOpen={showCreateModal}
|
|
||||||
onClose={() => setShowCreateModal(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { apiClient } from "@/shared/api/axios.instance";
|
|
||||||
|
|
||||||
const getAuthHeader = () => {
|
|
||||||
const raw = localStorage.getItem("auth-storage");
|
|
||||||
if (raw) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (parsed?.state?.token) return `bearer ${parsed.state.token}`;
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface AdminUserDto {
|
|
||||||
id: number;
|
|
||||||
login: string;
|
|
||||||
name: string;
|
|
||||||
last_name: string;
|
|
||||||
is_active: boolean;
|
|
||||||
permission_admin: boolean;
|
|
||||||
permission_manage_agent: boolean;
|
|
||||||
permission_view: boolean;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateUserPayload {
|
|
||||||
login: string;
|
|
||||||
name: string;
|
|
||||||
last_name: string;
|
|
||||||
password: string;
|
|
||||||
is_active: boolean;
|
|
||||||
permission_admin: boolean;
|
|
||||||
permission_manage_agent: boolean;
|
|
||||||
permission_view: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PermissionsPayload {
|
|
||||||
is_active: boolean;
|
|
||||||
permission_admin: boolean;
|
|
||||||
permission_manage_agent: boolean;
|
|
||||||
permission_view: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const adminApi = {
|
|
||||||
getUsers: async (): Promise<AdminUserDto[]> => {
|
|
||||||
const res = await apiClient.get<AdminUserDto[]>("/auth/tokens", {
|
|
||||||
headers: { Authorization: getAuthHeader() },
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
createUser: async (payload: CreateUserPayload): Promise<void> => {
|
|
||||||
await apiClient.post("/auth/token", payload, {
|
|
||||||
headers: { Authorization: getAuthHeader() },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteUser: async (login: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/auth/tokens/${login}`, {
|
|
||||||
headers: { Authorization: getAuthHeader() },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
activateUser: async (login: string): Promise<void> => {
|
|
||||||
await apiClient.post(
|
|
||||||
`/auth/users/${login}/activate`,
|
|
||||||
{},
|
|
||||||
{ headers: { Authorization: getAuthHeader() } },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
deactivateUser: async (login: string): Promise<void> => {
|
|
||||||
await apiClient.post(
|
|
||||||
`/auth/users/${login}/deactivate`,
|
|
||||||
{},
|
|
||||||
{ headers: { Authorization: getAuthHeader() } },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
updatePermissions: async (
|
|
||||||
login: string,
|
|
||||||
payload: PermissionsPayload,
|
|
||||||
): Promise<void> => {
|
|
||||||
await apiClient.put(`/auth/users/${login}/permissions`, payload, {
|
|
||||||
headers: { Authorization: getAuthHeader() },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
generateToken: async (label: string): Promise<string> => {
|
|
||||||
const res = await apiClient.post<{ token: string }>(
|
|
||||||
"/agents/register-token",
|
|
||||||
{ label },
|
|
||||||
{ headers: { Authorization: getAuthHeader() } },
|
|
||||||
);
|
|
||||||
return res.data.token;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,310 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { FaTimes, FaPlus } from "react-icons/fa";
|
|
||||||
import { useAdminStore } from "../store/useAdminStore";
|
|
||||||
|
|
||||||
interface CreateUserModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CreateUserModal: React.FC<CreateUserModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const createUser = useAdminStore((s) => s.createUser);
|
|
||||||
|
|
||||||
const [form, setForm] = useState({
|
|
||||||
login: "",
|
|
||||||
name: "",
|
|
||||||
last_name: "",
|
|
||||||
password: "",
|
|
||||||
is_active: true,
|
|
||||||
permission_admin: false,
|
|
||||||
permission_manage_agent: false,
|
|
||||||
permission_view: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!form.login || !form.password) return;
|
|
||||||
setLoading(true);
|
|
||||||
await createUser(form);
|
|
||||||
setLoading(false);
|
|
||||||
setForm({
|
|
||||||
login: "",
|
|
||||||
name: "",
|
|
||||||
last_name: "",
|
|
||||||
password: "",
|
|
||||||
is_active: true,
|
|
||||||
permission_admin: false,
|
|
||||||
permission_manage_agent: false,
|
|
||||||
permission_view: true,
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
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={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
padding: "24px",
|
|
||||||
minWidth: "380px",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginBottom: "20px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
fontSize: "16px",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Создать пользователя
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaTimes size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
|
|
||||||
{/* Login */}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: "4px",
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Логин
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.login}
|
|
||||||
onChange={(e) => setForm({ ...form, login: e.target.value })}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Password */}
|
|
||||||
<div>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: "4px",
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Пароль
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={form.password}
|
|
||||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Name + Last name */}
|
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: "4px",
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Имя
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.name}
|
|
||||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: "4px",
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Фамилия
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={form.last_name}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm({ ...form, last_name: e.target.value })
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Permissions */}
|
|
||||||
<div style={{ paddingTop: "8px" }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
marginBottom: "8px",
|
|
||||||
display: "block",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Разрешения
|
|
||||||
</label>
|
|
||||||
<div style={{ display: "flex", gap: "12px", flexWrap: "wrap" }}>
|
|
||||||
{[
|
|
||||||
{ key: "is_active", label: "Active" },
|
|
||||||
{ key: "permission_view", label: "View" },
|
|
||||||
{ key: "permission_manage_agent", label: "Manage Agent" },
|
|
||||||
{ key: "permission_admin", label: "Admin" },
|
|
||||||
].map(({ key, label }) => (
|
|
||||||
<label
|
|
||||||
key={key}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
userSelect: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={
|
|
||||||
form[key as keyof typeof form] as boolean
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
|
||||||
setForm({ ...form, [key]: e.target.checked })
|
|
||||||
}
|
|
||||||
style={{ accentColor: "var(--accent)" }}
|
|
||||||
/>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<button
|
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={loading || !form.login || !form.password}
|
|
||||||
style={{
|
|
||||||
marginTop: "8px",
|
|
||||||
padding: "10px",
|
|
||||||
backgroundColor:
|
|
||||||
loading || !form.login || !form.password
|
|
||||||
? "var(--bg-secondary)"
|
|
||||||
: "var(--accent)",
|
|
||||||
color:
|
|
||||||
loading || !form.login || !form.password
|
|
||||||
? "var(--text-muted)"
|
|
||||||
: "var(--accent-text)",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "6px",
|
|
||||||
cursor:
|
|
||||||
loading || !form.login || !form.password
|
|
||||||
? "default"
|
|
||||||
: "pointer",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: 500,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaPlus size={12} />
|
|
||||||
{loading ? "Создание..." : "Создать"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,207 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { FaUser, FaCheck, FaTrash } from "react-icons/fa";
|
|
||||||
import type { AdminUser, PermissionKey } from "../types";
|
|
||||||
import { useAdminStore } from "../store/useAdminStore";
|
|
||||||
|
|
||||||
interface UserCardProps {
|
|
||||||
user: AdminUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions: { key: PermissionKey; label: string }[] = [
|
|
||||||
{ key: "permission_view", label: "View" },
|
|
||||||
{ key: "permission_manage_agent", label: "Manage Agent" },
|
|
||||||
{ key: "permission_admin", label: "Admin" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const UserCard: React.FC<UserCardProps> = ({ user }) => {
|
|
||||||
const users = useAdminStore((s) => s.users);
|
|
||||||
const toggleActive = useAdminStore((s) => s.toggleActive);
|
|
||||||
const togglePermission = useAdminStore((s) => s.togglePermission);
|
|
||||||
const deleteUser = useAdminStore((s) => s.deleteUser);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "16px",
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
transition: "all 0.2s",
|
|
||||||
opacity: user.is_active ? 1 : 0.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header: User info + Active toggle + Delete */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
marginBottom: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "40px",
|
|
||||||
height: "40px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: user.is_active
|
|
||||||
? "var(--accent)"
|
|
||||||
: "var(--text-muted)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaUser size={16} style={{ color: "var(--card-bg)" }} />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.name} {user.last_name}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.login}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
|
|
||||||
{/* Active toggle */}
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "11px",
|
|
||||||
color: user.is_active
|
|
||||||
? "var(--success-text, #22c55e)"
|
|
||||||
: "var(--error-text, #ef4444)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.is_active ? "Active" : "Inactive"}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => toggleActive(user.id, user.login, user.is_active)}
|
|
||||||
style={{
|
|
||||||
width: "40px",
|
|
||||||
height: "22px",
|
|
||||||
borderRadius: "11px",
|
|
||||||
border: "none",
|
|
||||||
backgroundColor: user.is_active ? "#22c55e" : "#6b7280",
|
|
||||||
cursor: "pointer",
|
|
||||||
position: "relative",
|
|
||||||
transition: "background-color 0.2s",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: "16px",
|
|
||||||
height: "16px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: "#fff",
|
|
||||||
position: "absolute",
|
|
||||||
top: "3px",
|
|
||||||
left: user.is_active ? "21px" : "3px",
|
|
||||||
transition: "left 0.2s",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete button */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (window.confirm(`Удалить пользователя "${user.login}"?`)) {
|
|
||||||
deleteUser(user.id, user.login);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
title="Удалить"
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "1px solid transparent",
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "6px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
transition: "all 0.15s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.color = "var(--error-text, #ef4444)";
|
|
||||||
e.currentTarget.style.backgroundColor = "rgba(239,68,68,0.1)";
|
|
||||||
e.currentTarget.style.borderColor = "rgba(239,68,68,0.3)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.color = "var(--text-muted)";
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
e.currentTarget.style.borderColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaTrash size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Permissions */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
gap: "16px",
|
|
||||||
paddingTop: "12px",
|
|
||||||
borderTop: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{permissions.map(({ key, label }) => (
|
|
||||||
<label
|
|
||||||
key={key}
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "6px",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
userSelect: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
onClick={() => togglePermission(user.id, user.login, key, users)}
|
|
||||||
style={{
|
|
||||||
width: "18px",
|
|
||||||
height: "18px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
border: "1px solid",
|
|
||||||
borderColor: user[key] ? "var(--accent)" : "var(--border)",
|
|
||||||
backgroundColor: user[key] ? "var(--accent)" : "transparent",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
transition: "all 0.15s",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user[key] && (
|
|
||||||
<FaCheck
|
|
||||||
size={10}
|
|
||||||
style={{ color: "var(--accent-text, #fff)" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{label}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { AdminPanel } from "./AdminPanel";
|
|
||||||
export { useAdminStore } from "./store/useAdminStore";
|
|
||||||
export { adminApi } from "./api/admin.api";
|
|
||||||
export type { AdminUser } from "./types";
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import type { AdminUser, PermissionKey } from "../types";
|
|
||||||
import { adminApi } from "../api/admin.api";
|
|
||||||
import type { CreateUserPayload } from "../api/admin.api";
|
|
||||||
|
|
||||||
interface AdminState {
|
|
||||||
users: AdminUser[];
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
fetchUsers: () => Promise<void>;
|
|
||||||
createUser: (payload: CreateUserPayload) => Promise<void>;
|
|
||||||
deleteUser: (id: string, login: string) => Promise<void>;
|
|
||||||
toggleActive: (id: string, login: string, current: boolean) => Promise<void>;
|
|
||||||
togglePermission: (
|
|
||||||
id: string,
|
|
||||||
login: string,
|
|
||||||
permission: PermissionKey,
|
|
||||||
users: AdminUser[],
|
|
||||||
) => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useAdminStore = create<AdminState>((set, get) => ({
|
|
||||||
users: [],
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
|
|
||||||
fetchUsers: async () => {
|
|
||||||
set({ loading: true, error: null });
|
|
||||||
try {
|
|
||||||
const data = await adminApi.getUsers();
|
|
||||||
set({
|
|
||||||
users: data.map((u) => ({
|
|
||||||
id: String(u.id),
|
|
||||||
login: u.login,
|
|
||||||
name: u.name,
|
|
||||||
last_name: u.last_name,
|
|
||||||
is_active: u.is_active,
|
|
||||||
permission_admin: u.permission_admin,
|
|
||||||
permission_manage_agent: u.permission_manage_agent,
|
|
||||||
permission_view: u.permission_view,
|
|
||||||
})),
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
set({
|
|
||||||
error: e instanceof Error ? e.message : "Failed to fetch users",
|
|
||||||
loading: false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
createUser: async (payload) => {
|
|
||||||
try {
|
|
||||||
await adminApi.createUser(payload);
|
|
||||||
await get().fetchUsers();
|
|
||||||
} catch (e) {
|
|
||||||
set({ error: e instanceof Error ? e.message : "Failed to create user" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteUser: async (id, login) => {
|
|
||||||
try {
|
|
||||||
await adminApi.deleteUser(login);
|
|
||||||
set((state) => ({
|
|
||||||
users: state.users.filter((u) => u.id !== id),
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
set({ error: e instanceof Error ? e.message : "Failed to delete user" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleActive: async (id, login, current) => {
|
|
||||||
try {
|
|
||||||
if (current) {
|
|
||||||
await adminApi.deactivateUser(login);
|
|
||||||
} else {
|
|
||||||
await adminApi.activateUser(login);
|
|
||||||
}
|
|
||||||
set((state) => ({
|
|
||||||
users: state.users.map((u) =>
|
|
||||||
u.id === id ? { ...u, is_active: !current } : u,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
set({
|
|
||||||
error: e instanceof Error ? e.message : "Failed to toggle active",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
togglePermission: async (id, login, permission, users) => {
|
|
||||||
const user = users.find((u) => u.id === id);
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
const newPermissions = {
|
|
||||||
is_active: user.is_active,
|
|
||||||
permission_admin:
|
|
||||||
permission === "permission_admin"
|
|
||||||
? !user.permission_admin
|
|
||||||
: user.permission_admin,
|
|
||||||
permission_manage_agent:
|
|
||||||
permission === "permission_manage_agent"
|
|
||||||
? !user.permission_manage_agent
|
|
||||||
: user.permission_manage_agent,
|
|
||||||
permission_view:
|
|
||||||
permission === "permission_view"
|
|
||||||
? !user.permission_view
|
|
||||||
: user.permission_view,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await adminApi.updatePermissions(login, newPermissions);
|
|
||||||
set((state) => ({
|
|
||||||
users: state.users.map((u) =>
|
|
||||||
u.id === id
|
|
||||||
? {
|
|
||||||
...u,
|
|
||||||
[permission]: !u[permission],
|
|
||||||
}
|
|
||||||
: u,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
set({
|
|
||||||
error: e instanceof Error ? e.message : "Failed to update permissions",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export interface AdminUser {
|
|
||||||
id: string;
|
|
||||||
login: string;
|
|
||||||
name: string;
|
|
||||||
last_name: string;
|
|
||||||
is_active: boolean;
|
|
||||||
permission_admin: boolean;
|
|
||||||
permission_manage_agent: boolean;
|
|
||||||
permission_view: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PermissionKey =
|
|
||||||
| "permission_admin"
|
|
||||||
| "permission_manage_agent"
|
|
||||||
| "permission_view";
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import { apiClient } from "@/shared/api/axios.instance";
|
|
||||||
import type {
|
|
||||||
AgentInfo,
|
|
||||||
TokenCreate,
|
|
||||||
TokenUser,
|
|
||||||
LogEntry,
|
|
||||||
LogFilters,
|
|
||||||
InsertLogRequest,
|
|
||||||
InsertLogsRequest,
|
|
||||||
TokenUpdate,
|
|
||||||
TokenUpdatePermissions,
|
|
||||||
TokenPasswordReset,
|
|
||||||
RegistrationRequest,
|
|
||||||
DeployAgentsRequest,
|
|
||||||
DeployResponse,
|
|
||||||
SystemMetrics,
|
|
||||||
} from "../types/agent.types";
|
|
||||||
import type { GraphApiResponse } from "@/modules/graph/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 Array.isArray(response.data) ? response.data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUsers(): Promise<TokenUser[]> {
|
|
||||||
const response = await apiClient.get<TokenUser[]>(
|
|
||||||
`${this.authBasePath}/tokens`,
|
|
||||||
);
|
|
||||||
return Array.isArray(response.data) ? 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 || undefined,
|
|
||||||
service: filters?.service || undefined,
|
|
||||||
agent: filters?.agent || undefined,
|
|
||||||
date_from: filters?.date_from || undefined,
|
|
||||||
date_to: filters?.date_to || undefined,
|
|
||||||
limit: filters?.limit ?? 100,
|
|
||||||
offset: filters?.offset ?? 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!Array.isArray(response.data)) {
|
|
||||||
console.error(
|
|
||||||
"[Logs] Unexpected response format:",
|
|
||||||
typeof response.data,
|
|
||||||
response.data,
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
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 Array.isArray(response.data) ? response.data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDistinctLevels(): Promise<string[]> {
|
|
||||||
const response = await apiClient.get<string[]>(
|
|
||||||
`${this.logsBasePath}/levels`,
|
|
||||||
);
|
|
||||||
return Array.isArray(response.data) ? response.data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDistinctServices(): Promise<string[]> {
|
|
||||||
const response = await apiClient.get<string[]>(
|
|
||||||
`${this.logsBasePath}/services`,
|
|
||||||
);
|
|
||||||
return Array.isArray(response.data) ? response.data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// User management methods
|
|
||||||
async getUserByLogin(login: string): Promise<TokenUser> {
|
|
||||||
const response = await apiClient.get<TokenUser>(
|
|
||||||
`${this.authBasePath}/users/${login}`,
|
|
||||||
);
|
|
||||||
if (!response.data || typeof response.data !== "object") {
|
|
||||||
throw new Error(`User not found: ${login}`);
|
|
||||||
}
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getInactiveUsers(): Promise<TokenUser[]> {
|
|
||||||
const response = await apiClient.get<TokenUser[]>(
|
|
||||||
`${this.authBasePath}/users/inactive`,
|
|
||||||
);
|
|
||||||
return Array.isArray(response.data) ? 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSystemMetrics(): Promise<SystemMetrics[]> {
|
|
||||||
const response = await apiClient.get<SystemMetrics[]>(
|
|
||||||
`${this.basePath}/system-metrics`,
|
|
||||||
);
|
|
||||||
return Array.isArray(response.data) ? response.data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGraph(): Promise<GraphApiResponse> {
|
|
||||||
const response = await apiClient.get<GraphApiResponse>("/graph");
|
|
||||||
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,87 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
export type LogLevel = "info" | "warning" | "error" | "fatal";
|
|
||||||
|
|
||||||
interface LogFilterState {
|
|
||||||
searchQuery: string;
|
|
||||||
startDate: Date | null;
|
|
||||||
endDate: Date | null;
|
|
||||||
selectedLogLevel: LogLevel | null;
|
|
||||||
selectedService: string;
|
|
||||||
selectedAgent: string;
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
|
|
||||||
setSearchQuery: (query: string) => void;
|
|
||||||
setStartDate: (date: Date | null) => void;
|
|
||||||
setEndDate: (date: Date | null) => void;
|
|
||||||
setSelectedLogLevel: (level: LogLevel | null) => 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,
|
|
||||||
selectedLogLevel: null,
|
|
||||||
selectedService: "",
|
|
||||||
selectedAgent: "",
|
|
||||||
limit: 100,
|
|
||||||
offset: 0,
|
|
||||||
|
|
||||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
|
||||||
setStartDate: (date) => set({ startDate: date }),
|
|
||||||
setEndDate: (date) => set({ endDate: date }),
|
|
||||||
setSelectedLogLevel: (level) => set({ selectedLogLevel: 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,
|
|
||||||
selectedLogLevel: null,
|
|
||||||
selectedService: "",
|
|
||||||
selectedAgent: "",
|
|
||||||
limit: 100,
|
|
||||||
offset: 0,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getFilters: () => {
|
|
||||||
const {
|
|
||||||
selectedLogLevel,
|
|
||||||
selectedService,
|
|
||||||
selectedAgent,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
} = get();
|
|
||||||
return {
|
|
||||||
level: selectedLogLevel || undefined,
|
|
||||||
service: selectedService || undefined,
|
|
||||||
agent: selectedAgent || undefined,
|
|
||||||
date_from: startDate ? startDate.toISOString() : undefined,
|
|
||||||
date_to: endDate ? endDate.toISOString() : undefined,
|
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
export interface AgentInfo {
|
|
||||||
token: string;
|
|
||||||
label: string;
|
|
||||||
services: string[];
|
|
||||||
connected_at: 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 | 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[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SystemMetrics {
|
|
||||||
connected_at: string;
|
|
||||||
cpu_percent: number;
|
|
||||||
disk_percent: number;
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
memory_percent: number;
|
|
||||||
network_rx_bytes: number;
|
|
||||||
network_tx_bytes: number;
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const DeployType = {
|
|
||||||
Docker: "docker",
|
|
||||||
Binary: "binary",
|
|
||||||
Deploy: "deploy",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
export const AuthMethod = {
|
|
||||||
Key: "key",
|
|
||||||
Password: "password",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export interface ExtraField {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SSHAgentConfig {
|
|
||||||
user: string;
|
|
||||||
ip: string;
|
|
||||||
authMethod: string;
|
|
||||||
sshKey?: string;
|
|
||||||
password?: string;
|
|
||||||
extraFields: ExtraField[];
|
|
||||||
deployType: string;
|
|
||||||
}
|
|
||||||
@@ -1,556 +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: "rgba(59, 130, 246, 0.1)",
|
|
||||||
text: "#3b82f6",
|
|
||||||
border: "rgba(59, 130, 246, 0.3)",
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
bg: "rgba(245, 158, 11, 0.1)",
|
|
||||||
text: "#f59e0b",
|
|
||||||
border: "rgba(245, 158, 11, 0.3)",
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
bg: "var(--error-bg)",
|
|
||||||
text: "var(--error-text)",
|
|
||||||
border: "var(--error-border)",
|
|
||||||
},
|
|
||||||
fatal: {
|
|
||||||
bg: "rgba(168, 85, 247, 0.1)",
|
|
||||||
text: "#a855f7",
|
|
||||||
border: "rgba(168, 85, 247, 0.3)",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LogFiltersProps {
|
|
||||||
onApply: () => void;
|
|
||||||
availableServices: string[];
|
|
||||||
availableAgents: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LogFilters: React.FC<LogFiltersProps> = ({
|
|
||||||
onApply,
|
|
||||||
availableServices,
|
|
||||||
availableAgents,
|
|
||||||
}) => {
|
|
||||||
const {
|
|
||||||
searchQuery,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
selectedLogLevel,
|
|
||||||
selectedService,
|
|
||||||
selectedAgent,
|
|
||||||
setSearchQuery,
|
|
||||||
setStartDate,
|
|
||||||
setEndDate,
|
|
||||||
setSelectedLogLevel,
|
|
||||||
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);
|
|
||||||
const [localLevel, setLocalLevel] = useState<LogLevel | null>(
|
|
||||||
selectedLogLevel,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalSearchQuery(searchQuery);
|
|
||||||
}, [searchQuery]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalStartDate(startDate);
|
|
||||||
}, [startDate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalEndDate(endDate);
|
|
||||||
}, [endDate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalService(selectedService);
|
|
||||||
}, [selectedService]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalAgent(selectedAgent);
|
|
||||||
}, [selectedAgent]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalLevel(selectedLogLevel);
|
|
||||||
}, [selectedLogLevel]);
|
|
||||||
|
|
||||||
const handleApply = useCallback(() => {
|
|
||||||
setSearchQuery(localSearchQuery);
|
|
||||||
setStartDate(localStartDate);
|
|
||||||
setEndDate(localEndDate);
|
|
||||||
setSelectedLogLevel(localLevel);
|
|
||||||
setSelectedService(localService);
|
|
||||||
setSelectedAgent(localAgent);
|
|
||||||
onApply();
|
|
||||||
}, [
|
|
||||||
localSearchQuery,
|
|
||||||
localStartDate,
|
|
||||||
localEndDate,
|
|
||||||
localLevel,
|
|
||||||
localService,
|
|
||||||
localAgent,
|
|
||||||
onApply,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
setLocalSearchQuery("");
|
|
||||||
setLocalStartDate(null);
|
|
||||||
setLocalEndDate(null);
|
|
||||||
setLocalLevel(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 (selectedLogLevel) 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 sm: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, minWidth: 0 }}
|
|
||||||
placeholder="Дата от"
|
|
||||||
className="flex-1 min-w-0"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={
|
|
||||||
localEndDate ? localEndDate.toISOString().split("T")[0] : ""
|
|
||||||
}
|
|
||||||
onChange={(e) =>
|
|
||||||
setLocalEndDate(
|
|
||||||
e.target.value ? new Date(e.target.value) : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
style={{ ...inputStyle, minWidth: 0 }}
|
|
||||||
placeholder="Дата до"
|
|
||||||
className="flex-1 min-w-0"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{(["info", "warning", "error", "fatal"] as LogLevel[]).map(
|
|
||||||
(level) => {
|
|
||||||
const isSelected = localLevel === level;
|
|
||||||
const colors = logLevelColors[level];
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={level}
|
|
||||||
onClick={() => setLocalLevel(isSelected ? null : level)}
|
|
||||||
className="px-3 py-2 rounded-lg text-xs font-medium transition-all border flex-shrink-0"
|
|
||||||
style={{
|
|
||||||
backgroundColor: isSelected ? colors.bg : "transparent",
|
|
||||||
color: isSelected ? colors.text : "var(--text-secondary)",
|
|
||||||
borderColor: isSelected ? colors.border : "var(--border)",
|
|
||||||
minHeight: "36px",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (isSelected) {
|
|
||||||
e.currentTarget.style.backgroundColor = colors.text;
|
|
||||||
e.currentTarget.style.color = "#fff";
|
|
||||||
} else {
|
|
||||||
e.currentTarget.style.backgroundColor =
|
|
||||||
"rgba(128, 128, 128, 0.08)";
|
|
||||||
e.currentTarget.style.color = "var(--text-primary)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = isSelected
|
|
||||||
? colors.bg
|
|
||||||
: "transparent";
|
|
||||||
e.currentTarget.style.color = isSelected
|
|
||||||
? colors.text
|
|
||||||
: "var(--text-secondary)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isSelected && (
|
|
||||||
<FiCheck size={10} className="inline mr-1" />
|
|
||||||
)}
|
|
||||||
{level.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="flex flex-col sm:flex-row gap-2">
|
|
||||||
<button
|
|
||||||
onClick={handleApply}
|
|
||||||
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--button-primary)",
|
|
||||||
color: "var(--button-primary-text)",
|
|
||||||
minHeight: "44px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiCheck size={14} />
|
|
||||||
Применить
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleReset}
|
|
||||||
className="flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg transition-all text-sm font-medium border"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
minHeight: "44px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
{selectedLogLevel &&
|
|
||||||
(() => {
|
|
||||||
const colors = logLevelColors[selectedLogLevel];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded-lg border text-xs"
|
|
||||||
style={{
|
|
||||||
backgroundColor: colors.bg,
|
|
||||||
borderColor: colors.border,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiTag size={10} style={{ color: colors.text }} />
|
|
||||||
<span style={{ color: colors.text }}>
|
|
||||||
Уровень: {selectedLogLevel.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setLocalLevel(null);
|
|
||||||
setSelectedLogLevel(null);
|
|
||||||
onApply();
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
color: colors.text,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,544 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
FiServer,
|
|
||||||
FiGlobe,
|
|
||||||
FiKey,
|
|
||||||
FiLock,
|
|
||||||
FiPlus,
|
|
||||||
FiTrash2,
|
|
||||||
FiSettings,
|
|
||||||
FiLink,
|
|
||||||
} from "react-icons/fi";
|
|
||||||
import { SiDocker } from "react-icons/si";
|
|
||||||
import { FiPackage, FiUploadCloud } from "react-icons/fi";
|
|
||||||
|
|
||||||
type DeployType = "docker" | "binary" | "deploy";
|
|
||||||
type AuthMethod = "key" | "password";
|
|
||||||
|
|
||||||
interface ExtraField {
|
|
||||||
key: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SSHAgentConfig {
|
|
||||||
agentLabel: string;
|
|
||||||
user: string;
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
authMethod: AuthMethod;
|
|
||||||
sshKey?: string;
|
|
||||||
password?: string;
|
|
||||||
extraFields: ExtraField[];
|
|
||||||
deployType: DeployType;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SSHAgentFormProps {
|
|
||||||
index: number;
|
|
||||||
config: SSHAgentConfig;
|
|
||||||
onChange: (index: number, config: SSHAgentConfig) => void;
|
|
||||||
onRemove: (index: number) => void;
|
|
||||||
canRemove: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEPLOY_OPTIONS: {
|
|
||||||
value: DeployType;
|
|
||||||
label: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
}[] = [
|
|
||||||
{ value: "docker", label: "Docker", icon: <SiDocker /> },
|
|
||||||
{ value: "binary", label: "Binary", icon: <FiPackage /> },
|
|
||||||
];
|
|
||||||
|
|
||||||
const inputBaseStyle: 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SSHAgentForm: React.FC<SSHAgentFormProps> = ({
|
|
||||||
index,
|
|
||||||
config,
|
|
||||||
onChange,
|
|
||||||
onRemove,
|
|
||||||
canRemove,
|
|
||||||
}) => {
|
|
||||||
const handleChange = (field: keyof SSHAgentConfig, value: unknown) => {
|
|
||||||
onChange(index, { ...config, [field]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExtraFieldChange = (
|
|
||||||
fieldIndex: number,
|
|
||||||
field: keyof ExtraField,
|
|
||||||
value: string,
|
|
||||||
) => {
|
|
||||||
const newExtraFields = [...config.extraFields];
|
|
||||||
newExtraFields[fieldIndex] = {
|
|
||||||
...newExtraFields[fieldIndex],
|
|
||||||
[field]: value,
|
|
||||||
};
|
|
||||||
handleChange("extraFields", newExtraFields);
|
|
||||||
};
|
|
||||||
|
|
||||||
const addExtraField = () => {
|
|
||||||
handleChange("extraFields", [
|
|
||||||
...config.extraFields,
|
|
||||||
{ key: "", value: "" },
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeExtraField = (fieldIndex: number) => {
|
|
||||||
const newExtraFields = config.extraFields.filter(
|
|
||||||
(_, i) => i !== fieldIndex,
|
|
||||||
);
|
|
||||||
handleChange("extraFields", newExtraFields);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFocus = (
|
|
||||||
e: React.FocusEvent<
|
|
||||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
|
||||||
>,
|
|
||||||
) => {
|
|
||||||
e.currentTarget.style.borderColor = "var(--border-focus)";
|
|
||||||
e.currentTarget.style.boxShadow = `0 0 0 3px var(--border-focus)30`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBlur = (
|
|
||||||
e: React.FocusEvent<
|
|
||||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
|
||||||
>,
|
|
||||||
) => {
|
|
||||||
e.currentTarget.style.borderColor = "var(--border)";
|
|
||||||
e.currentTarget.style.boxShadow = "none";
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
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: "24px",
|
|
||||||
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)" }}
|
|
||||||
>
|
|
||||||
<FiServer style={{ color: "var(--accent)", fontSize: "20px" }} />
|
|
||||||
</div>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "18px",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
SSH сервер #{index + 1}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
{canRemove && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onRemove(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>
|
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "20px" }}>
|
|
||||||
{/* Agent Label */}
|
|
||||||
<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
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr 1fr",
|
|
||||||
gap: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
|
||||||
>
|
|
||||||
<FiServer size={14} />
|
|
||||||
Пользователь *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.user}
|
|
||||||
onChange={(e) => handleChange("user", e.target.value)}
|
|
||||||
required
|
|
||||||
style={inputBaseStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="username"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
|
||||||
>
|
|
||||||
<FiGlobe size={14} />
|
|
||||||
IP адрес *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={config.ip}
|
|
||||||
onChange={(e) => handleChange("ip", e.target.value)}
|
|
||||||
required
|
|
||||||
style={inputBaseStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="192.168.1.1"
|
|
||||||
/>
|
|
||||||
</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>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
||||||
<FiKey size={14} />
|
|
||||||
Метод аутентификации *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div style={{ display: "flex", gap: "8px" }}>
|
|
||||||
{(["key", "password"] as const).map((method) => (
|
|
||||||
<button
|
|
||||||
key={method}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleChange("authMethod", method)}
|
|
||||||
className="flex-1 py-2.5 px-4 rounded-lg border transition-all font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
config.authMethod === method
|
|
||||||
? "var(--accent)"
|
|
||||||
: "var(--input-bg)",
|
|
||||||
color:
|
|
||||||
config.authMethod === method
|
|
||||||
? "var(--accent-text)"
|
|
||||||
: "var(--text-primary)",
|
|
||||||
borderColor:
|
|
||||||
config.authMethod === method
|
|
||||||
? "var(--accent)"
|
|
||||||
: "var(--border)",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "14px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{method === "key" ? "SSH ключ" : "Пароль"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* SSH Key или Password */}
|
|
||||||
{config.authMethod === "key" ? (
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
|
||||||
>
|
|
||||||
<FiKey size={14} />
|
|
||||||
SSH ключ *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={config.sshKey || ""}
|
|
||||||
onChange={(e) => handleChange("sshKey", e.target.value)}
|
|
||||||
required
|
|
||||||
rows={4}
|
|
||||||
style={{
|
|
||||||
...inputBaseStyle,
|
|
||||||
fontFamily: "ui-monospace, SFMono-Regular, monospace",
|
|
||||||
resize: "vertical",
|
|
||||||
}}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
|
||||||
>
|
|
||||||
<FiLock size={14} />
|
|
||||||
Пароль *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={config.password || ""}
|
|
||||||
onChange={(e) => handleChange("password", e.target.value)}
|
|
||||||
required
|
|
||||||
style={inputBaseStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Дополнительные поля */}
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
marginBottom: "12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<label style={{ ...labelStyle, marginBottom: 0 }}>
|
|
||||||
<span
|
|
||||||
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
|
||||||
>
|
|
||||||
<FiSettings size={14} />
|
|
||||||
Дополнительные параметры
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addExtraField}
|
|
||||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all"
|
|
||||||
style={{
|
|
||||||
background: "var(--accent)",
|
|
||||||
color: "var(--accent-text)",
|
|
||||||
border: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.opacity = "0.85";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.opacity = "1";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiPlus size={14} />
|
|
||||||
Добавить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{config.extraFields.length === 0 && (
|
|
||||||
<div
|
|
||||||
className="text-center py-6 rounded-lg border border-dashed"
|
|
||||||
style={{
|
|
||||||
color: "var(--text-muted)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiSettings
|
|
||||||
size={20}
|
|
||||||
style={{ margin: "0 auto 8px", opacity: 0.5 }}
|
|
||||||
/>
|
|
||||||
<p style={{ margin: 0, fontSize: "13px" }}>
|
|
||||||
Нет дополнительных параметров
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{config.extraFields.map((extra, fieldIndex) => (
|
|
||||||
<div
|
|
||||||
key={fieldIndex}
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr auto",
|
|
||||||
gap: "8px",
|
|
||||||
marginBottom: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={extra.key}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleExtraFieldChange(fieldIndex, "key", e.target.value)
|
|
||||||
}
|
|
||||||
style={inputBaseStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="Параметр"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={extra.value}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleExtraFieldChange(fieldIndex, "value", e.target.value)
|
|
||||||
}
|
|
||||||
style={inputBaseStyle}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
placeholder="Значение"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeExtraField(fieldIndex)}
|
|
||||||
className="flex items-center justify-center rounded-lg border transition-all"
|
|
||||||
style={{
|
|
||||||
background: "var(--error-bg)",
|
|
||||||
color: "var(--error-text)",
|
|
||||||
borderColor: "var(--error-border)",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "18px",
|
|
||||||
padding: "8px 12px",
|
|
||||||
}}
|
|
||||||
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)";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Тип развертывания */}
|
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>
|
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: "6px" }}>
|
|
||||||
<FiUploadCloud size={16} />
|
|
||||||
Тип развертывания *
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "1fr 1fr",
|
|
||||||
gap: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{DEPLOY_OPTIONS.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleChange("deployType", option.value)}
|
|
||||||
className="flex items-center justify-center gap-2 py-3 px-4 rounded-lg border transition-all font-medium"
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
config.deployType === option.value
|
|
||||||
? "var(--accent)"
|
|
||||||
: "var(--input-bg)",
|
|
||||||
color:
|
|
||||||
config.deployType === option.value
|
|
||||||
? "var(--accent-text)"
|
|
||||||
: "var(--text-primary)",
|
|
||||||
borderColor:
|
|
||||||
config.deployType === option.value
|
|
||||||
? "var(--accent)"
|
|
||||||
: "var(--border)",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "14px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span style={{ fontSize: "18px" }}>{option.icon}</span>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { persist } from "zustand/middleware";
|
|
||||||
import { apiClient } from "@/shared/api/axios.instance";
|
|
||||||
import type {
|
|
||||||
AuthState,
|
|
||||||
LoginCredentials,
|
|
||||||
RegisterData,
|
|
||||||
User,
|
|
||||||
LoginResponse,
|
|
||||||
} from "../types/auth.types";
|
|
||||||
|
|
||||||
const login = async (credentials: LoginCredentials): Promise<LoginResponse> => {
|
|
||||||
const response = await apiClient.post<LoginResponse>(
|
|
||||||
"/auth/login",
|
|
||||||
credentials,
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const register = async (
|
|
||||||
data: RegisterData,
|
|
||||||
): Promise<Record<string, string>> => {
|
|
||||||
const response = await apiClient.post<Record<string, string>>(
|
|
||||||
"/auth/register",
|
|
||||||
{
|
|
||||||
login: data.login,
|
|
||||||
password: data.password,
|
|
||||||
name: data.firstName,
|
|
||||||
last_name: data.lastName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return response.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapResponseToUser = (response: LoginResponse): User => ({
|
|
||||||
login: response.login,
|
|
||||||
name: response.name,
|
|
||||||
last_name: response.last_name,
|
|
||||||
permission_admin: response.permission_admin,
|
|
||||||
permission_manage_agent: response.permission_manage_agent,
|
|
||||||
permission_view: response.permission_view,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>()(
|
|
||||||
persist(
|
|
||||||
(set) => ({
|
|
||||||
user: null,
|
|
||||||
token: null,
|
|
||||||
isLoading: false,
|
|
||||||
error: null,
|
|
||||||
|
|
||||||
login: async (credentials: LoginCredentials) => {
|
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
try {
|
|
||||||
const response = await login(credentials);
|
|
||||||
const user = mapResponseToUser(response);
|
|
||||||
set({ user, token: response.token, isLoading: false });
|
|
||||||
} catch (error) {
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : "Login failed",
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
register: async (data: RegisterData) => {
|
|
||||||
set({ isLoading: true, error: null });
|
|
||||||
try {
|
|
||||||
await register(data);
|
|
||||||
// После регистрации пользователь не авторизуется автоматически
|
|
||||||
// Нужно войти через /auth/login
|
|
||||||
set({ isLoading: false });
|
|
||||||
} catch (error) {
|
|
||||||
set({
|
|
||||||
error:
|
|
||||||
error instanceof Error ? error.message : "Registration failed",
|
|
||||||
isLoading: false,
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
logout: () => {
|
|
||||||
set({ user: null, token: null, error: null });
|
|
||||||
},
|
|
||||||
|
|
||||||
clearError: () => {
|
|
||||||
set({ error: null });
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
name: "auth-storage",
|
|
||||||
partialize: (state) => ({ token: state.token, user: state.user }),
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
export interface LoginCredentials {
|
|
||||||
login: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RegisterData {
|
|
||||||
login: string;
|
|
||||||
password: string;
|
|
||||||
firstName: string;
|
|
||||||
lastName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
name: string;
|
|
||||||
login: string;
|
|
||||||
last_name: string;
|
|
||||||
permission_admin: boolean;
|
|
||||||
permission_manage_agent: boolean;
|
|
||||||
permission_view: boolean;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
login: string;
|
|
||||||
name: string;
|
|
||||||
last_name: string;
|
|
||||||
permission_admin: boolean;
|
|
||||||
permission_manage_agent: boolean;
|
|
||||||
permission_view: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthState {
|
|
||||||
user: User | null;
|
|
||||||
token: string | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
login: (credentials: LoginCredentials) => Promise<void>;
|
|
||||||
register: (data: RegisterData) => Promise<void>;
|
|
||||||
logout: () => void;
|
|
||||||
clearError: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Theme = "light" | "dark";
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { FaPlus } from "react-icons/fa";
|
|
||||||
|
|
||||||
interface AddWidgetButtonProps {
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddWidgetButton: React.FC<AddWidgetButtonProps> = ({
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={onClick}
|
|
||||||
className="w-full py-1.5 bg-tertiary hover:bg-tertiary/70 rounded-lg border border-primary transition-colors flex items-center justify-center gap-1 cursor-pointer"
|
|
||||||
>
|
|
||||||
<FaPlus size={10} className="text-tertiary" />
|
|
||||||
<span className="text-[10px] text-secondary">Добавить график</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
import React, { useState } from "react";
|
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import type { ChartType } from "../types";
|
|
||||||
|
|
||||||
interface AddWidgetModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onAdd: (data: { type: ChartType; title: string; dataKey: string }) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddWidgetModal: React.FC<AddWidgetModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onAdd,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const [type, setType] = useState<ChartType>("line");
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [dataKey, setDataKey] = useState("requests");
|
|
||||||
|
|
||||||
const handleAdd = () => {
|
|
||||||
if (!title.trim()) return;
|
|
||||||
onAdd({ type, title: title.trim(), dataKey });
|
|
||||||
setTitle("");
|
|
||||||
setType("line");
|
|
||||||
setDataKey("requests");
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnimatePresence>
|
|
||||||
{isOpen && (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.95, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
exit={{ scale: 0.95, opacity: 0 }}
|
|
||||||
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h3 className="text-xs font-semibold text-primary mb-3">
|
|
||||||
Добавить график
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<label className="block text-[10px] text-secondary mb-1">
|
|
||||||
Тип
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => setType(t)}
|
|
||||||
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
|
|
||||||
type === t
|
|
||||||
? "bg-accent-primary text-white"
|
|
||||||
: "bg-tertiary text-secondary hover:bg-tertiary/70"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t === "line" && "📈"}
|
|
||||||
{t === "bar" && "📊"}
|
|
||||||
{t === "area" && "📉"}
|
|
||||||
{t === "pie" && "🥧"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-[10px] text-secondary mb-1">
|
|
||||||
Название
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
placeholder="Название"
|
|
||||||
className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-1 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={handleAdd}
|
|
||||||
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Добавить
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
// modules/dashboard/components/ChartWidget.tsx
|
|
||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
|
||||||
import {
|
|
||||||
FaChartLine,
|
|
||||||
FaChartBar,
|
|
||||||
FaChartArea,
|
|
||||||
FaChartPie,
|
|
||||||
FaCog,
|
|
||||||
FaEye,
|
|
||||||
FaEyeSlash,
|
|
||||||
} from "react-icons/fa";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import type { ChartWidget as ChartWidgetType, MetricData } from "../types";
|
|
||||||
|
|
||||||
interface ChartWidgetProps {
|
|
||||||
widget: ChartWidgetType;
|
|
||||||
data: MetricData[];
|
|
||||||
onEdit: () => void;
|
|
||||||
onToggleVisibility: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Все возможные уровни логов (метрики)
|
|
||||||
const METRICS = ["INFO", "WARN", "ERROR", "DEBUG"];
|
|
||||||
|
|
||||||
// Цвета для каждой метрики
|
|
||||||
const METRIC_COLORS: Record<string, string> = {
|
|
||||||
INFO: "#10b981", // зеленый
|
|
||||||
WARN: "#f59e0b", // оранжевый
|
|
||||||
ERROR: "#ef4444", // красный
|
|
||||||
DEBUG: "#3b82f6", // синий
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ChartWidget: React.FC<ChartWidgetProps> = ({
|
|
||||||
widget,
|
|
||||||
data,
|
|
||||||
onEdit,
|
|
||||||
onToggleVisibility,
|
|
||||||
}) => {
|
|
||||||
const renderChart = () => {
|
|
||||||
if (!data || !Array.isArray(data) || data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<span className="text-[10px] text-tertiary">Нет данных</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedData = data.map((point) => {
|
|
||||||
const normalized: MetricData = { timestamp: point.timestamp };
|
|
||||||
METRICS.forEach((metric) => {
|
|
||||||
normalized[metric] = point[metric] || 0;
|
|
||||||
});
|
|
||||||
return normalized;
|
|
||||||
});
|
|
||||||
|
|
||||||
const commonProps = {
|
|
||||||
data: normalizedData,
|
|
||||||
margin: { top: 5, right: 10, left: 0, bottom: 5 },
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (widget.type) {
|
|
||||||
case "line":
|
|
||||||
return (
|
|
||||||
<LineChart {...commonProps}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="timestamp"
|
|
||||||
stroke="#64748b"
|
|
||||||
tick={{ fontSize: 9 }}
|
|
||||||
interval={Math.floor(normalizedData.length / 5)}
|
|
||||||
/>
|
|
||||||
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "#1e293b",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
borderRadius: "6px",
|
|
||||||
fontSize: "10px",
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: "#fff" }}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: "10px" }}
|
|
||||||
verticalAlign="top"
|
|
||||||
height={25}
|
|
||||||
/>
|
|
||||||
{METRICS.map((metric) => (
|
|
||||||
<Line
|
|
||||||
key={metric}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={metric}
|
|
||||||
stroke={METRIC_COLORS[metric]}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
name={metric}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "bar":
|
|
||||||
return (
|
|
||||||
<BarChart {...commonProps}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="timestamp"
|
|
||||||
stroke="#64748b"
|
|
||||||
tick={{ fontSize: 9 }}
|
|
||||||
interval={Math.floor(normalizedData.length / 5)}
|
|
||||||
/>
|
|
||||||
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "#1e293b",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
borderRadius: "6px",
|
|
||||||
fontSize: "10px",
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: "#fff" }}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: "10px" }}
|
|
||||||
verticalAlign="top"
|
|
||||||
height={25}
|
|
||||||
/>
|
|
||||||
{METRICS.map((metric) => (
|
|
||||||
<Bar
|
|
||||||
key={metric}
|
|
||||||
dataKey={metric}
|
|
||||||
fill={METRIC_COLORS[metric]}
|
|
||||||
radius={[2, 2, 0, 0]}
|
|
||||||
name={metric}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</BarChart>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "area":
|
|
||||||
return (
|
|
||||||
<AreaChart {...commonProps}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="timestamp"
|
|
||||||
stroke="#64748b"
|
|
||||||
tick={{ fontSize: 9 }}
|
|
||||||
interval={Math.floor(normalizedData.length / 5)}
|
|
||||||
/>
|
|
||||||
<YAxis stroke="#64748b" tick={{ fontSize: 9 }} width={30} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "#1e293b",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
borderRadius: "6px",
|
|
||||||
fontSize: "10px",
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: "#fff" }}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: "10px" }}
|
|
||||||
verticalAlign="top"
|
|
||||||
height={25}
|
|
||||||
/>
|
|
||||||
{METRICS.map((metric) => (
|
|
||||||
<Area
|
|
||||||
key={metric}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={metric}
|
|
||||||
stroke={METRIC_COLORS[metric]}
|
|
||||||
fill={METRIC_COLORS[metric]}
|
|
||||||
fillOpacity={0.2}
|
|
||||||
name={metric}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</AreaChart>
|
|
||||||
);
|
|
||||||
|
|
||||||
case "pie":
|
|
||||||
// Для круговой диаграммы берем последнюю точку
|
|
||||||
const lastPoint = normalizedData[normalizedData.length - 1];
|
|
||||||
const pieData = METRICS.map((metric) => ({
|
|
||||||
name: metric,
|
|
||||||
value: lastPoint[metric] || 0,
|
|
||||||
})).filter((item) => Number(item.value) > 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={pieData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={40}
|
|
||||||
outerRadius={55}
|
|
||||||
paddingAngle={3}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="name"
|
|
||||||
>
|
|
||||||
{pieData.map((entry, index) => (
|
|
||||||
<Cell key={`cell-${index}`} fill={METRIC_COLORS[entry.name]} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{
|
|
||||||
backgroundColor: "#1e293b",
|
|
||||||
border: "1px solid #334155",
|
|
||||||
borderRadius: "6px",
|
|
||||||
fontSize: "10px",
|
|
||||||
}}
|
|
||||||
labelStyle={{ color: "#fff" }}
|
|
||||||
/>
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: "10px" }}
|
|
||||||
layout="vertical"
|
|
||||||
verticalAlign="middle"
|
|
||||||
align="right"
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIcon = () => {
|
|
||||||
switch (widget.type) {
|
|
||||||
case "line":
|
|
||||||
return <FaChartLine size={10} />;
|
|
||||||
case "bar":
|
|
||||||
return <FaChartBar size={10} />;
|
|
||||||
case "area":
|
|
||||||
return <FaChartArea size={10} />;
|
|
||||||
case "pie":
|
|
||||||
return <FaChartPie size={10} />;
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
layout
|
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
|
||||||
className={`bg-secondary rounded-lg border border-primary p-2 transition-all ${!widget.visible ? "opacity-50" : ""}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between mb-1 px-1">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-tertiary">{getIcon()}</span>
|
|
||||||
<h3 className="text-[11px] font-medium text-primary">
|
|
||||||
{widget.title}
|
|
||||||
</h3>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-0.5">
|
|
||||||
<button
|
|
||||||
onClick={onToggleVisibility}
|
|
||||||
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
|
|
||||||
title={widget.visible ? "Скрыть" : "Показать"}
|
|
||||||
>
|
|
||||||
{widget.visible ? (
|
|
||||||
<FaEye size={9} className="text-tertiary" />
|
|
||||||
) : (
|
|
||||||
<FaEyeSlash size={9} className="text-tertiary" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onEdit}
|
|
||||||
className="p-0.5 hover:bg-tertiary rounded transition-colors cursor-pointer"
|
|
||||||
title="Настройки"
|
|
||||||
>
|
|
||||||
<FaCog size={9} className="text-tertiary" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-40">
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
{renderChart()}
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
// modules/dashboard/components/WidgetSettings.tsx
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import type { ChartType, ChartWidget } from "../types";
|
|
||||||
|
|
||||||
interface WidgetSettingsProps {
|
|
||||||
widget: ChartWidget;
|
|
||||||
onUpdate: (widget: ChartWidget) => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WidgetSettings: React.FC<WidgetSettingsProps> = ({
|
|
||||||
widget,
|
|
||||||
onUpdate,
|
|
||||||
onRemove,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const [type, setType] = useState<ChartType>(widget.type);
|
|
||||||
const [title, setTitle] = useState(widget.title);
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
onUpdate({ ...widget, type, title });
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<motion.div
|
|
||||||
initial={{ scale: 0.95, opacity: 0 }}
|
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
|
||||||
exit={{ scale: 0.95, opacity: 0 }}
|
|
||||||
className="bg-secondary rounded-xl shadow-large border border-primary w-80 p-3"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<h3 className="text-xs font-semibold text-primary mb-3">
|
|
||||||
Настройки графика
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div>
|
|
||||||
<label className="block text-[10px] text-secondary mb-1">Тип</label>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{(["line", "bar", "area", "pie"] as ChartType[]).map((t) => (
|
|
||||||
<button
|
|
||||||
key={t}
|
|
||||||
onClick={() => setType(t)}
|
|
||||||
className={`px-2 py-0.5 rounded text-[10px] transition-colors cursor-pointer ${
|
|
||||||
type === t
|
|
||||||
? "bg-accent-primary text-white"
|
|
||||||
: "bg-tertiary text-secondary hover:bg-tertiary/70"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t === "line" && "📈"}
|
|
||||||
{t === "bar" && "📊"}
|
|
||||||
{t === "area" && "📉"}
|
|
||||||
{t === "pie" && "🥧"}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-[10px] text-secondary mb-1">
|
|
||||||
Название
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
className="w-full px-2 py-1 text-[11px] bg-tertiary border border-primary rounded text-primary focus:outline-none focus:border-accent-primary"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-1 pt-2">
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
className="flex-1 px-2 py-1 bg-accent-primary text-white rounded text-[10px] hover:bg-accent-hover transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onRemove}
|
|
||||||
className="px-2 py-1 bg-red-500/10 text-red-500 rounded text-[10px] hover:bg-red-500/20 transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-2 py-1 bg-tertiary text-secondary rounded text-[10px] hover:bg-secondary transition-colors cursor-pointer"
|
|
||||||
>
|
|
||||||
Отмена
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
LineChart,
|
|
||||||
Line,
|
|
||||||
AreaChart,
|
|
||||||
Area,
|
|
||||||
BarChart,
|
|
||||||
Bar,
|
|
||||||
PieChart,
|
|
||||||
Pie,
|
|
||||||
Cell,
|
|
||||||
XAxis,
|
|
||||||
YAxis,
|
|
||||||
CartesianGrid,
|
|
||||||
Tooltip,
|
|
||||||
ResponsiveContainer,
|
|
||||||
Legend,
|
|
||||||
} from "recharts";
|
|
||||||
import { motion } from "framer-motion";
|
|
||||||
import type { ChartType, MetricData } from "../types";
|
|
||||||
|
|
||||||
interface DashboardChartProps {
|
|
||||||
title: string;
|
|
||||||
type: ChartType;
|
|
||||||
data: MetricData[];
|
|
||||||
dataKeys: string[];
|
|
||||||
colors?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const COLORS = ["#3b82f6", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6"];
|
|
||||||
|
|
||||||
export const DashboardChart: React.FC<DashboardChartProps> = ({
|
|
||||||
title,
|
|
||||||
type,
|
|
||||||
data,
|
|
||||||
dataKeys,
|
|
||||||
colors = COLORS,
|
|
||||||
}) => {
|
|
||||||
const renderChart = () => {
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Нет данных
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const commonProps = {
|
|
||||||
data,
|
|
||||||
margin: { top: 5, right: 10, left: 0, bottom: 5 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const axisStyle = {
|
|
||||||
stroke: "var(--text-secondary)",
|
|
||||||
tick: { fontSize: 10 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const tooltipStyle = {
|
|
||||||
contentStyle: {
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "6px",
|
|
||||||
fontSize: "11px",
|
|
||||||
},
|
|
||||||
labelStyle: { color: "var(--text-primary)" },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (type === "pie") {
|
|
||||||
// Если данные уже в формате { name, value } — используем напрямую
|
|
||||||
const isPieFormat =
|
|
||||||
data.length > 0 && "name" in data[0] && "value" in data[0];
|
|
||||||
|
|
||||||
const pieData = isPieFormat
|
|
||||||
? data
|
|
||||||
: data.map((point, i) => ({
|
|
||||||
name: dataKeys[i % dataKeys.length],
|
|
||||||
value: point[dataKeys[i % dataKeys.length]] || 0,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={pieData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={40}
|
|
||||||
outerRadius={60}
|
|
||||||
paddingAngle={3}
|
|
||||||
dataKey="value"
|
|
||||||
nameKey="name"
|
|
||||||
>
|
|
||||||
{pieData.map((entry, index) => (
|
|
||||||
<Cell
|
|
||||||
key={`cell-${index}`}
|
|
||||||
fill={colors[index % colors.length]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip {...tooltipStyle} />
|
|
||||||
<Legend
|
|
||||||
wrapperStyle={{ fontSize: "11px" }}
|
|
||||||
layout="vertical"
|
|
||||||
verticalAlign="middle"
|
|
||||||
align="right"
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChartComponent =
|
|
||||||
type === "line" ? LineChart : type === "area" ? AreaChart : BarChart;
|
|
||||||
const DataComponent = type === "line" ? Line : type === "area" ? Area : Bar;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ChartComponent {...commonProps}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="timestamp"
|
|
||||||
{...axisStyle}
|
|
||||||
interval={Math.floor(data.length / 5)}
|
|
||||||
/>
|
|
||||||
<YAxis {...axisStyle} width={35} />
|
|
||||||
<Tooltip {...tooltipStyle} />
|
|
||||||
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
|
||||||
{dataKeys.map((key, i) => (
|
|
||||||
<DataComponent
|
|
||||||
key={key}
|
|
||||||
type="monotone"
|
|
||||||
dataKey={key}
|
|
||||||
stroke={colors[i % colors.length]}
|
|
||||||
fill={colors[i % colors.length]}
|
|
||||||
fillOpacity={type === "area" ? 0.2 : undefined}
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
name={key}
|
|
||||||
radius={type === "bar" ? [2, 2, 0, 0] : undefined}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</ChartComponent>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
layout
|
|
||||||
initial={{ opacity: 0, scale: 0.98 }}
|
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
|
||||||
style={{
|
|
||||||
padding: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: 600,
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
marginBottom: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</h3>
|
|
||||||
<div style={{ height: 180 }}>
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
{renderChart()}
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
// modules/dashboard/Dashboard.tsx
|
|
||||||
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useDashboardStore } from "./store/dashboard.store";
|
|
||||||
import { useAuthStore } from "../auth/store/useAuthStore";
|
|
||||||
import { ChartWidget } from "./components/chart,widget";
|
|
||||||
import { AddWidgetButton } from "./components/add.widget.button";
|
|
||||||
import { AddWidgetModal } from "./components/add.widget.modal";
|
|
||||||
import { WidgetSettings } from "./components/chart.settings";
|
|
||||||
import { useWidgets } from "./hooks/use.widget";
|
|
||||||
|
|
||||||
export const Dashboard: React.FC = () => {
|
|
||||||
const { chartData, loading, error, fetchMetrics, clearData } =
|
|
||||||
useDashboardStore();
|
|
||||||
// const { servicesQueryParams } = useAgentStore();
|
|
||||||
const intervalRef = useRef<number | null>(null);
|
|
||||||
|
|
||||||
const { token } = useAuthStore();
|
|
||||||
|
|
||||||
// Первичная загрузка (не latest)
|
|
||||||
// const fetchPrimaryData = () => {
|
|
||||||
// fetchMetrics(false, token || "", servicesQueryParams, { since: "10m" });
|
|
||||||
// };
|
|
||||||
|
|
||||||
// Периодическое обновление (latest)
|
|
||||||
// const fetchLatestData = () => {
|
|
||||||
// fetchMetrics(true, token || "", servicesQueryParams);
|
|
||||||
// };
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// fetchPrimaryData();
|
|
||||||
// }, []);
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// intervalRef.current = window.setInterval(() => {
|
|
||||||
// fetchLatestData();
|
|
||||||
// }, 30000);
|
|
||||||
|
|
||||||
// return () => {
|
|
||||||
// if (intervalRef.current) {
|
|
||||||
// window.clearInterval(intervalRef.current);
|
|
||||||
// }
|
|
||||||
// clearData();
|
|
||||||
// };
|
|
||||||
// }, [servicesQueryParams]);
|
|
||||||
|
|
||||||
const { widgets, addWidget, updateWidget, removeWidget, toggleVisibility } =
|
|
||||||
useWidgets();
|
|
||||||
const [editingWidget, setEditingWidget] = useState<any>(null);
|
|
||||||
const [isAdding, setIsAdding] = useState(false);
|
|
||||||
|
|
||||||
const visibleWidgets = widgets.filter((w) => w.visible);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-4">
|
|
||||||
{loading && chartData.length === 0 ? (
|
|
||||||
<div className="flex items-center justify-center h-40">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-accent-primary border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
) : error ? (
|
|
||||||
<div className="flex items-center justify-center h-40">
|
|
||||||
<span className="text-[10px] text-red-500">{error}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 mb-4">
|
|
||||||
{visibleWidgets.map((widget) => (
|
|
||||||
<ChartWidget
|
|
||||||
key={widget.id}
|
|
||||||
widget={widget}
|
|
||||||
data={chartData}
|
|
||||||
onEdit={() => setEditingWidget(widget)}
|
|
||||||
onToggleVisibility={() => toggleVisibility(widget.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AddWidgetButton onClick={() => setIsAdding(true)} />
|
|
||||||
|
|
||||||
<AddWidgetModal
|
|
||||||
isOpen={isAdding}
|
|
||||||
onAdd={addWidget}
|
|
||||||
onClose={() => setIsAdding(false)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{editingWidget && (
|
|
||||||
<WidgetSettings
|
|
||||||
widget={editingWidget}
|
|
||||||
onUpdate={updateWidget}
|
|
||||||
onRemove={() => removeWidget(editingWidget.id)}
|
|
||||||
onClose={() => setEditingWidget(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import type { ChartType, ChartWidget } from "../types";
|
|
||||||
|
|
||||||
const initialWidgets: ChartWidget[] = [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
type: "line",
|
|
||||||
title: "Линии",
|
|
||||||
dataKey: "chart-line",
|
|
||||||
visible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "2",
|
|
||||||
type: "bar",
|
|
||||||
title: "Столбцы",
|
|
||||||
dataKey: "chart-bar",
|
|
||||||
visible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "3",
|
|
||||||
type: "area",
|
|
||||||
title: "Закрашенные линии",
|
|
||||||
dataKey: "chart-area",
|
|
||||||
visible: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "4",
|
|
||||||
type: "pie",
|
|
||||||
title: "Круговая диаграмма",
|
|
||||||
dataKey: "chart-pie",
|
|
||||||
visible: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const useWidgets = () => {
|
|
||||||
const [widgets, setWidgets] = useState<ChartWidget[]>(initialWidgets);
|
|
||||||
|
|
||||||
const addWidget = (data: {
|
|
||||||
type: ChartType;
|
|
||||||
title: string;
|
|
||||||
dataKey: string;
|
|
||||||
}) => {
|
|
||||||
const newWidget: ChartWidget = {
|
|
||||||
id: Date.now().toString(),
|
|
||||||
...data,
|
|
||||||
visible: true,
|
|
||||||
};
|
|
||||||
setWidgets([...widgets, newWidget]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateWidget = (updated: ChartWidget) => {
|
|
||||||
setWidgets(widgets.map((w) => (w.id === updated.id ? updated : w)));
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeWidget = (id: string) => {
|
|
||||||
setWidgets(widgets.filter((w) => w.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleVisibility = (id: string) => {
|
|
||||||
setWidgets(
|
|
||||||
widgets.map((w) => (w.id === id ? { ...w, visible: !w.visible } : w)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
widgets,
|
|
||||||
addWidget,
|
|
||||||
updateWidget,
|
|
||||||
removeWidget,
|
|
||||||
toggleVisibility,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,129 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import { apiService } from "@/shared/api/api.service";
|
|
||||||
import type { MetricData } from "../types";
|
|
||||||
|
|
||||||
interface DashboardState {
|
|
||||||
chartData: MetricData[];
|
|
||||||
loading: boolean;
|
|
||||||
error: string | null;
|
|
||||||
fetchMetrics: (
|
|
||||||
isLatest: boolean,
|
|
||||||
token: string,
|
|
||||||
queryParams?: string,
|
|
||||||
extraParams?: Record<string, string>,
|
|
||||||
) => Promise<void>;
|
|
||||||
clearData: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useDashboardStore = create<DashboardState>((set, get) => {
|
|
||||||
const convertPrimaryData = (response: any) => {
|
|
||||||
set((state) => {
|
|
||||||
if (!response.intervals || !Array.isArray(response.intervals))
|
|
||||||
return { chartData: state.chartData };
|
|
||||||
|
|
||||||
const newData = [...state.chartData];
|
|
||||||
|
|
||||||
response.intervals.forEach((interval: any) => {
|
|
||||||
const newPoint: MetricData = {
|
|
||||||
timestamp: new Date(interval.timestamp).toLocaleTimeString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (interval.group_by && Array.isArray(interval.group_by)) {
|
|
||||||
interval.group_by.forEach((item: any) => {
|
|
||||||
newPoint[item.value] = item.count;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
newData.push(newPoint);
|
|
||||||
});
|
|
||||||
|
|
||||||
return { chartData: newData.slice(-20) };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const convertSingleData = (response: any) => {
|
|
||||||
set((state) => {
|
|
||||||
const newPoint: MetricData = {
|
|
||||||
timestamp: new Date().toLocaleTimeString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (Array.isArray(response)) {
|
|
||||||
response.forEach((item: any) => {
|
|
||||||
newPoint[item.value] = item.count;
|
|
||||||
});
|
|
||||||
} else if (response.groupBy && Array.isArray(response.groupBy)) {
|
|
||||||
response.groupBy.forEach((item: any) => {
|
|
||||||
newPoint[item.value] = item.count;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedData = [...state.chartData, newPoint].slice(-20);
|
|
||||||
return { chartData: updatedData };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchMetrics = async (
|
|
||||||
isLatest: boolean,
|
|
||||||
token: string,
|
|
||||||
queryParams?: string,
|
|
||||||
extraParams?: Record<string, string>,
|
|
||||||
) => {
|
|
||||||
set({ loading: true, error: null });
|
|
||||||
|
|
||||||
try {
|
|
||||||
let endpoint = isLatest
|
|
||||||
? "logs/aggregations/latest"
|
|
||||||
: "logs/aggregations";
|
|
||||||
|
|
||||||
// Если есть queryParams, добавляем его к эндпоинту
|
|
||||||
if (queryParams && queryParams.trim() !== "") {
|
|
||||||
endpoint = `${endpoint}?${queryParams}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const params: Record<string, string> = {
|
|
||||||
agg: "count",
|
|
||||||
groupby: "level",
|
|
||||||
...extraParams,
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await apiService.get<any>(endpoint, {
|
|
||||||
params,
|
|
||||||
headers: {
|
|
||||||
Authorization: `bearer ${token}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
if (isLatest) {
|
|
||||||
convertSingleData(result);
|
|
||||||
} else {
|
|
||||||
convertPrimaryData(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Failed to fetch ${isLatest ? "latest" : "primary"} metrics:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
set({
|
|
||||||
error: error instanceof Error ? error.message : "Ошибка запроса",
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
set({ loading: false });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearData = () => {
|
|
||||||
set({ chartData: [], error: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
chartData: [],
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
fetchMetrics,
|
|
||||||
clearData,
|
|
||||||
setChartData: (data: MetricData[]) =>
|
|
||||||
set({ chartData: data, loading: false }),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
export type ChartType = "line" | "bar" | "area" | "pie";
|
|
||||||
|
|
||||||
export interface ChartWidget {
|
|
||||||
id: string;
|
|
||||||
type: ChartType;
|
|
||||||
title: string;
|
|
||||||
dataKey: string;
|
|
||||||
visible: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MetricData {
|
|
||||||
timestamp: string;
|
|
||||||
[key: string]: number | string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StatsItem {
|
|
||||||
label: string;
|
|
||||||
key: string;
|
|
||||||
icon: string;
|
|
||||||
color: string;
|
|
||||||
suffix?: string;
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import React, { useRef, useEffect, useState } from "react";
|
|
||||||
import type {
|
|
||||||
GraphData,
|
|
||||||
GraphNode,
|
|
||||||
GraphLink,
|
|
||||||
ContextMenuState,
|
|
||||||
} from "./types";
|
|
||||||
import { useGraphStore } from "./store/useGraphStore";
|
|
||||||
import {
|
|
||||||
ForceGraph,
|
|
||||||
GraphControls,
|
|
||||||
GraphContextMenu,
|
|
||||||
GraphStatusBar,
|
|
||||||
GraphStats,
|
|
||||||
} from "./components";
|
|
||||||
|
|
||||||
interface GraphProps {
|
|
||||||
initialData?: GraphData;
|
|
||||||
onExport?: () => void;
|
|
||||||
onDataChange?: (data: GraphData) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Graph: React.FC<GraphProps> = ({
|
|
||||||
initialData,
|
|
||||||
onExport,
|
|
||||||
onDataChange,
|
|
||||||
}) => {
|
|
||||||
const fgRef = useRef<any>(null);
|
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
|
|
||||||
|
|
||||||
const data = useGraphStore((s) => s.data);
|
|
||||||
const isLinkMode = useGraphStore((s) => s.isLinkMode);
|
|
||||||
const selectedNode = useGraphStore((s) => s.selectedNode);
|
|
||||||
const setData = useGraphStore((s) => s.setData);
|
|
||||||
|
|
||||||
// Инициализация данных
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData) setData(initialData);
|
|
||||||
}, [initialData, setData]);
|
|
||||||
|
|
||||||
// Закрыть контекстное меню по клику вне
|
|
||||||
useEffect(() => {
|
|
||||||
const handleClickOutside = () => setContextMenu(null);
|
|
||||||
document.addEventListener("click", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("click", handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNodeRightClick = (node: GraphNode, event: MouseEvent) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
setContextMenu({ x: event.clientX, y: event.clientY, node, link: null });
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="p-4 h-full flex flex-col"
|
|
||||||
style={{ backgroundColor: "var(--card-bg)" }}
|
|
||||||
>
|
|
||||||
{/* Статистика сверху */}
|
|
||||||
<GraphStats data={data} />
|
|
||||||
|
|
||||||
{/* Граф */}
|
|
||||||
<div
|
|
||||||
className="flex-1 rounded-lg overflow-hidden relative mt-2"
|
|
||||||
style={{ border: "1px solid var(--border)" }}
|
|
||||||
>
|
|
||||||
<ForceGraph
|
|
||||||
ref={fgRef}
|
|
||||||
data={data}
|
|
||||||
onNodeRightClick={handleNodeRightClick}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GraphContextMenu
|
|
||||||
menu={contextMenu}
|
|
||||||
data={data}
|
|
||||||
onClose={() => setContextMenu(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<GraphStatusBar isLinkMode={isLinkMode} selectedNode={selectedNode} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Кнопки снизу */}
|
|
||||||
<GraphControls
|
|
||||||
fgRef={fgRef}
|
|
||||||
onExport={onExport}
|
|
||||||
onDataChange={onDataChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Graph;
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import React, {
|
|
||||||
useRef,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
useState,
|
|
||||||
forwardRef,
|
|
||||||
} from "react";
|
|
||||||
import ForceGraph2D from "react-force-graph-2d";
|
|
||||||
import type { GraphData, GraphNode, GraphLink } from "../types";
|
|
||||||
import { useGraphStore } from "../store/useGraphStore";
|
|
||||||
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
|
|
||||||
|
|
||||||
interface ForceGraphProps {
|
|
||||||
data: GraphData;
|
|
||||||
onNodeRightClick: (node: GraphNode, event: MouseEvent) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ForceGraph = forwardRef<any, ForceGraphProps>(
|
|
||||||
({ data, onNodeRightClick }, ref) => {
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [dimensions, setDimensions] = useState({ width: 480, height: 600 });
|
|
||||||
|
|
||||||
const highlightNodes = useGraphStore((s) => s.highlightNodes);
|
|
||||||
const highlightLinks = useGraphStore((s) => s.highlightLinks);
|
|
||||||
const selectedNode = useGraphStore((s) => s.selectedNode);
|
|
||||||
const isLinkMode = useGraphStore((s) => s.isLinkMode);
|
|
||||||
const theme = useThemeStore((s) => s.theme);
|
|
||||||
const isDark = theme === "dark";
|
|
||||||
|
|
||||||
// Определяем цвета текста в зависимости от темы
|
|
||||||
const nodeTextColor = isDark ? "#e5e7eb" : "#1f2937";
|
|
||||||
const nodeTextLetterColor = isDark ? "#ffffff" : "#000000";
|
|
||||||
|
|
||||||
// ResizeObserver для корректного отслеживания размеров
|
|
||||||
useEffect(() => {
|
|
||||||
const container = containerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const updateDimensions = () => {
|
|
||||||
setDimensions({
|
|
||||||
width: container.clientWidth,
|
|
||||||
height: container.clientHeight,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
updateDimensions();
|
|
||||||
|
|
||||||
const observer = new ResizeObserver(updateDimensions);
|
|
||||||
observer.observe(container);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNodeClick = useCallback((node: GraphNode) => {
|
|
||||||
const store = useGraphStore.getState();
|
|
||||||
if (store.isLinkMode) {
|
|
||||||
if (store.selectedNode === null) {
|
|
||||||
store.setSelectedNode(node);
|
|
||||||
} else if (store.selectedNode.id !== node.id) {
|
|
||||||
store.createLink(store.selectedNode.id, node.id);
|
|
||||||
store.setSelectedNode(null);
|
|
||||||
store.toggleLinkMode();
|
|
||||||
} else {
|
|
||||||
store.setSelectedNode(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleNodeHover = (node: GraphNode | null) => {
|
|
||||||
const newHighlightNodes = new Set<string>();
|
|
||||||
const newHighlightLinks = new Set<GraphLink>();
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
useGraphStore
|
|
||||||
.getState()
|
|
||||||
.setHighlight(newHighlightNodes, newHighlightLinks);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNodeColor = (node: GraphNode) => {
|
|
||||||
if (selectedNode?.id === node.id && isLinkMode) return "#f97316";
|
|
||||||
|
|
||||||
if (node.type === "service" && node.status === "down") {
|
|
||||||
// Проверяем, есть ли зависимости этого сервиса, которые тоже упали
|
|
||||||
const hasDownDependency = data.links.some((link) => {
|
|
||||||
const sourceId =
|
|
||||||
typeof link.source === "object"
|
|
||||||
? (link.source as any).id
|
|
||||||
: link.source;
|
|
||||||
const targetId =
|
|
||||||
typeof link.target === "object"
|
|
||||||
? (link.target as any).id
|
|
||||||
: link.target;
|
|
||||||
|
|
||||||
if (sourceId !== node.id) return false;
|
|
||||||
|
|
||||||
const isDependency =
|
|
||||||
link.type === "dependency" || link.type === "started";
|
|
||||||
const targetIsDown = data.nodes.some(
|
|
||||||
(n) => n.id === targetId && n.status === "down",
|
|
||||||
);
|
|
||||||
|
|
||||||
return isDependency && targetIsDown;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Если есть упавшая зависимость — не подсвечиваем красным
|
|
||||||
if (hasDownDependency) return "#3b82f6";
|
|
||||||
return "#ef4444";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.type === "agent") {
|
|
||||||
// Проверяем, есть ли у агента хотя бы один упавший сервис
|
|
||||||
const hasDownService = data.nodes.some(
|
|
||||||
(n) =>
|
|
||||||
n.type === "service" &&
|
|
||||||
n.status === "down" &&
|
|
||||||
n.id.startsWith(`${node.id}-`),
|
|
||||||
);
|
|
||||||
if (hasDownService) return "#ef4444";
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = nodeTextLetterColor;
|
|
||||||
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 = nodeTextColor;
|
|
||||||
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 handleEngineStop = () => {
|
|
||||||
if (typeof ref !== "function" && ref && "current" in ref && ref.current) {
|
|
||||||
ref.current.zoomToFit(400);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="w-full h-full relative">
|
|
||||||
<ForceGraph2D
|
|
||||||
ref={ref}
|
|
||||||
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={onNodeRightClick}
|
|
||||||
onNodeHover={handleNodeHover}
|
|
||||||
cooldownTicks={50}
|
|
||||||
cooldownTime={2000}
|
|
||||||
d3AlphaDecay={0.03}
|
|
||||||
d3VelocityDecay={0.4}
|
|
||||||
warmupTicks={50}
|
|
||||||
onEngineStop={handleEngineStop}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
ForceGraph.displayName = "ForceGraph";
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { FiLink, FiTrash2 } from "react-icons/fi";
|
|
||||||
import type { ContextMenuState, GraphNode, GraphData } from "../types";
|
|
||||||
import { useGraphStore } from "../store/useGraphStore";
|
|
||||||
|
|
||||||
interface GraphContextMenuProps {
|
|
||||||
menu: ContextMenuState | null;
|
|
||||||
data: GraphData;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GraphContextMenu: React.FC<GraphContextMenuProps> = ({
|
|
||||||
menu,
|
|
||||||
data,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const removeNode = useGraphStore((s) => s.removeNode);
|
|
||||||
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
|
|
||||||
const setSelectedNode = useGraphStore((s) => s.setSelectedNode);
|
|
||||||
|
|
||||||
if (!menu) return null;
|
|
||||||
|
|
||||||
const handleDeleteNode = (node: GraphNode) => {
|
|
||||||
removeNode(node.id);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateLink = (node: GraphNode) => {
|
|
||||||
toggleLinkMode();
|
|
||||||
setSelectedNode(node);
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="fixed rounded-lg shadow-lg py-1 z-50"
|
|
||||||
style={{
|
|
||||||
top: menu.y,
|
|
||||||
left: menu.x,
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{menu.node && (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className="px-3 py-1 text-xs border-b"
|
|
||||||
style={{
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
borderColor: "var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{menu.node.name}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCreateLink(menu.node!)}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm flex items-center gap-2"
|
|
||||||
style={{ color: "var(--text-primary)" }}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = "var(--bg-secondary)")
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = "transparent")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FiLink size={14} /> Создать связь
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDeleteNode(menu.node!)}
|
|
||||||
className="w-full text-left px-4 py-2 text-sm flex items-center gap-2"
|
|
||||||
style={{ color: "#f87171" }}
|
|
||||||
onMouseEnter={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = "rgba(248,113,113,0.1)")
|
|
||||||
}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.backgroundColor = "transparent")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FiTrash2 size={14} /> Удалить узел
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
FiDownload,
|
|
||||||
FiZoomIn,
|
|
||||||
FiZoomOut,
|
|
||||||
FiMove,
|
|
||||||
FiLink,
|
|
||||||
} from "react-icons/fi";
|
|
||||||
import { useGraphStore } from "../store/useGraphStore";
|
|
||||||
import type { GraphData } from "../types";
|
|
||||||
|
|
||||||
interface GraphControlsProps {
|
|
||||||
fgRef: React.RefObject<any>;
|
|
||||||
onExport?: () => void;
|
|
||||||
onDataChange?: (data: GraphData) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const btnStyle: React.CSSProperties = {
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const GraphControls: React.FC<GraphControlsProps> = ({
|
|
||||||
fgRef,
|
|
||||||
onExport,
|
|
||||||
onDataChange,
|
|
||||||
}) => {
|
|
||||||
const isLinkMode = useGraphStore((s) => s.isLinkMode);
|
|
||||||
const toggleLinkMode = useGraphStore((s) => s.toggleLinkMode);
|
|
||||||
const exportData = useGraphStore((s) => s.exportData);
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-end gap-2 mt-2">
|
|
||||||
{/* Режим создания связи */}
|
|
||||||
{/* <button
|
|
||||||
onClick={toggleLinkMode}
|
|
||||||
className="flex w-full items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm"
|
|
||||||
style={{
|
|
||||||
backgroundColor: isLinkMode ? "#22c55e" : "var(--bg-secondary)",
|
|
||||||
color: isLinkMode ? "#fff" : "var(--text-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FiLink />
|
|
||||||
<span>{isLinkMode ? "Создание связи..." : "Добавить связь"}</span>
|
|
||||||
</button> */}
|
|
||||||
|
|
||||||
{/* Зум + */}
|
|
||||||
<button
|
|
||||||
onClick={handleZoomIn}
|
|
||||||
className="p-2 rounded-lg transition-colors"
|
|
||||||
style={btnStyle}
|
|
||||||
>
|
|
||||||
<FiZoomIn />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Зум - */}
|
|
||||||
<button
|
|
||||||
onClick={handleZoomOut}
|
|
||||||
className="p-2 rounded-lg transition-colors"
|
|
||||||
style={btnStyle}
|
|
||||||
>
|
|
||||||
<FiZoomOut />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Fit */}
|
|
||||||
<button
|
|
||||||
onClick={handleFit}
|
|
||||||
className="p-2 rounded-lg transition-colors"
|
|
||||||
style={btnStyle}
|
|
||||||
>
|
|
||||||
<FiMove />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Экспорт */}
|
|
||||||
<button
|
|
||||||
onClick={onExport || exportData}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 rounded-lg transition-colors text-sm"
|
|
||||||
style={btnStyle}
|
|
||||||
>
|
|
||||||
<FiDownload />
|
|
||||||
<span>Экспорт</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import type { GraphData } from "../types";
|
|
||||||
|
|
||||||
interface GraphStatsProps {
|
|
||||||
data: GraphData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GraphStats: React.FC<GraphStatsProps> = ({ data }) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex gap-4 text-xs"
|
|
||||||
style={{ color: "var(--text-secondary)" }}
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
Сервисы: {data.nodes.filter((n) => n.type === "service").length}
|
|
||||||
</span>
|
|
||||||
<span>Агенты: {data.nodes.filter((n) => n.type === "agent").length}</span>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-sm"
|
|
||||||
style={{ backgroundColor: "var(--text-muted)" }}
|
|
||||||
></div>
|
|
||||||
<span>Связи: {data.links.length}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import { FiLink } from "react-icons/fi";
|
|
||||||
import type { GraphNode } from "../types";
|
|
||||||
|
|
||||||
interface GraphStatusBarProps {
|
|
||||||
isLinkMode: boolean;
|
|
||||||
selectedNode: GraphNode | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GraphStatusBar: React.FC<GraphStatusBarProps> = ({
|
|
||||||
isLinkMode,
|
|
||||||
selectedNode,
|
|
||||||
}) => {
|
|
||||||
if (!isLinkMode) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="absolute bottom-4 left-4 text-white px-3 py-1 rounded-lg text-sm flex items-center gap-2"
|
|
||||||
style={{ backgroundColor: "#22c55e" }}
|
|
||||||
>
|
|
||||||
<FiLink /> Режим создания связей: кликните на два узла для соединения
|
|
||||||
{selectedNode && (
|
|
||||||
<span className="ml-2">Выбран: {selectedNode.name}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { ForceGraph } from "./ForceGraph";
|
|
||||||
export { GraphControls } from "./GraphControls";
|
|
||||||
export { GraphContextMenu } from "./GraphContextMenu";
|
|
||||||
export { GraphStatusBar } from "./GraphStatusBar";
|
|
||||||
export { GraphStats } from "./GraphStats";
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { Graph } from "./Graph";
|
|
||||||
export { useGraphStore } from "./store/useGraphStore";
|
|
||||||
export type { GraphData, GraphNode, GraphLink } from "./types";
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import type { GraphData, GraphNode, GraphLink } from "../types";
|
|
||||||
|
|
||||||
interface GraphState {
|
|
||||||
data: GraphData;
|
|
||||||
highlightNodes: Set<string>;
|
|
||||||
highlightLinks: Set<GraphLink>;
|
|
||||||
isLinkMode: boolean;
|
|
||||||
selectedNode: GraphNode | null;
|
|
||||||
|
|
||||||
// Действия с данными
|
|
||||||
setData: (data: GraphData) => void;
|
|
||||||
addNode: (node: GraphNode) => void;
|
|
||||||
removeNode: (nodeId: string) => void;
|
|
||||||
addLink: (link: GraphLink) => void;
|
|
||||||
removeLink: (link: GraphLink) => void;
|
|
||||||
|
|
||||||
// Подсветка
|
|
||||||
setHighlight: (nodeIds: Set<string>, links: Set<GraphLink>) => void;
|
|
||||||
|
|
||||||
// Режим связи
|
|
||||||
toggleLinkMode: () => void;
|
|
||||||
setSelectedNode: (node: GraphNode | null) => void;
|
|
||||||
createLink: (sourceId: string, targetId: string) => void;
|
|
||||||
|
|
||||||
// Экспорт
|
|
||||||
exportData: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useGraphStore = create<GraphState>((set, get) => ({
|
|
||||||
data: { nodes: [], links: [] },
|
|
||||||
highlightNodes: new Set(),
|
|
||||||
highlightLinks: new Set(),
|
|
||||||
isLinkMode: false,
|
|
||||||
selectedNode: null,
|
|
||||||
|
|
||||||
setData: (data) => set({ data }),
|
|
||||||
|
|
||||||
addNode: (node) => {
|
|
||||||
set((state) => ({
|
|
||||||
data: {
|
|
||||||
...state.data,
|
|
||||||
nodes: [...state.data.nodes, node],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
removeNode: (nodeId) => {
|
|
||||||
set((state) => ({
|
|
||||||
data: {
|
|
||||||
nodes: state.data.nodes.filter((n) => n.id !== nodeId),
|
|
||||||
links: state.data.links.filter(
|
|
||||||
(l) => l.source !== nodeId && l.target !== nodeId,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
addLink: (link) => {
|
|
||||||
set((state) => ({
|
|
||||||
data: {
|
|
||||||
...state.data,
|
|
||||||
links: [...state.data.links, link],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
removeLink: (linkToRemove) => {
|
|
||||||
set((state) => ({
|
|
||||||
data: {
|
|
||||||
...state.data,
|
|
||||||
links: state.data.links.filter((l) => l !== linkToRemove),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
setHighlight: (nodeIds, links) =>
|
|
||||||
set({ highlightNodes: nodeIds, highlightLinks: links }),
|
|
||||||
|
|
||||||
toggleLinkMode: () =>
|
|
||||||
set((state) => ({
|
|
||||||
isLinkMode: !state.isLinkMode,
|
|
||||||
selectedNode: null,
|
|
||||||
})),
|
|
||||||
|
|
||||||
setSelectedNode: (node) => set({ selectedNode: node }),
|
|
||||||
|
|
||||||
createLink: (sourceId, targetId) => {
|
|
||||||
const { data, addLink } = get();
|
|
||||||
|
|
||||||
const linkExists = data.links.some(
|
|
||||||
(link) =>
|
|
||||||
(link.source === sourceId && link.target === targetId) ||
|
|
||||||
(link.source === targetId && link.target === sourceId),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!linkExists) {
|
|
||||||
addLink({ source: sourceId, target: targetId, type: "custom" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
exportData: () => {
|
|
||||||
const { data } = get();
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
export interface GraphNode {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
type: "agent" | "service";
|
|
||||||
val?: number;
|
|
||||||
description?: string;
|
|
||||||
x?: number;
|
|
||||||
y?: number;
|
|
||||||
status?: "up" | "down";
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphLink {
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphData {
|
|
||||||
nodes: GraphNode[];
|
|
||||||
links: GraphLink[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ContextMenuState {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
node: GraphNode | null;
|
|
||||||
link: GraphLink | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// API response types for GET /graph
|
|
||||||
export interface GraphDependencyTarget {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphDependency {
|
|
||||||
condition: string;
|
|
||||||
target: GraphDependencyTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphServiceNode {
|
|
||||||
dependencies: GraphDependency[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphAgentNode {
|
|
||||||
services: Record<string, GraphServiceNode>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GraphApiResponse {
|
|
||||||
nodes: Record<string, GraphAgentNode>;
|
|
||||||
}
|
|
||||||
@@ -1,338 +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";
|
|
||||||
import { useThemeStore } from "@/modules/theme-bw/stores/theme.store";
|
|
||||||
|
|
||||||
interface IDEProps {
|
|
||||||
initialFiles?: FileNode;
|
|
||||||
onBack?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const darkColors = {
|
|
||||||
bg: "#1e1e1e",
|
|
||||||
bgSecondary: "#252526",
|
|
||||||
bgTertiary: "#2d2d30",
|
|
||||||
border: "#3e3e42",
|
|
||||||
textPrimary: "#cccccc",
|
|
||||||
textSecondary: "#858585",
|
|
||||||
accent: "#0e639c",
|
|
||||||
accentHover: "#1177bb",
|
|
||||||
statusBar: "#007acc",
|
|
||||||
};
|
|
||||||
|
|
||||||
const lightColors = {
|
|
||||||
bg: "#ffffff",
|
|
||||||
bgSecondary: "#f3f3f3",
|
|
||||||
bgTertiary: "#e8e8e8",
|
|
||||||
border: "#e0e0e0",
|
|
||||||
textPrimary: "#333333",
|
|
||||||
textSecondary: "#616161",
|
|
||||||
accent: "#0e639c",
|
|
||||||
accentHover: "#1177bb",
|
|
||||||
statusBar: "#007acc",
|
|
||||||
};
|
|
||||||
|
|
||||||
export const IDE: React.FC<IDEProps> = ({
|
|
||||||
initialFiles: externalFiles,
|
|
||||||
onBack,
|
|
||||||
}: IDEProps = {}) => {
|
|
||||||
const theme = useThemeStore((s) => s.theme);
|
|
||||||
const isDark = theme === "dark";
|
|
||||||
const c = isDark ? darkColors : lightColors;
|
|
||||||
|
|
||||||
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 saveActiveFile = useIDEStore((state) => state.saveActiveFile);
|
|
||||||
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);
|
|
||||||
const fetchTree = useIDEStore((state) => state.fetchTree);
|
|
||||||
const fetchInterpreters = useIDEStore((state) => state.fetchInterpreters);
|
|
||||||
|
|
||||||
// Загружаем интерпретаторы при инициализации
|
|
||||||
useEffect(() => {
|
|
||||||
fetchInterpreters();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Обработка Ctrl+S
|
|
||||||
useEffect(() => {
|
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "s") {
|
|
||||||
e.preventDefault();
|
|
||||||
saveActiveFile();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [saveActiveFile]);
|
|
||||||
|
|
||||||
// При загрузке пробуем загрузить дерево с сервера
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isInitialized) {
|
|
||||||
fetchTree().catch(() => {
|
|
||||||
// Только при ошибке — используем моковые данные
|
|
||||||
const state = useIDEStore.getState();
|
|
||||||
if (!state.files) {
|
|
||||||
const filesToInit = externalFiles || defaultInitialFiles;
|
|
||||||
initialize(filesToInit);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [isInitialized]);
|
|
||||||
|
|
||||||
// Если проект не открыт
|
|
||||||
if (!files) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100vh",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
backgroundColor: c.bg,
|
|
||||||
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 ${c.border}`,
|
|
||||||
color: c.textPrimary,
|
|
||||||
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 = c.border;
|
|
||||||
e.currentTarget.style.color = "#fff";
|
|
||||||
e.currentTarget.style.borderColor = "#555";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
e.currentTarget.style.color = c.textPrimary;
|
|
||||||
e.currentTarget.style.borderColor = c.border;
|
|
||||||
}}
|
|
||||||
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: c.textPrimary,
|
|
||||||
fontWeight: 300,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No project open
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontSize: "13px",
|
|
||||||
marginBottom: "32px",
|
|
||||||
color: c.textSecondary,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Create a new project to get started
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={createNewProject}
|
|
||||||
style={{
|
|
||||||
padding: "10px 24px",
|
|
||||||
backgroundColor: c.accent,
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#fff",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: 500,
|
|
||||||
transition: "background-color 0.1s",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = c.accentHover;
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = c.accent;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdAdd size={14} /> New Project
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBar activeFile={null} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100vh",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
overflow: "hidden",
|
|
||||||
backgroundColor: c.bg,
|
|
||||||
fontFamily:
|
|
||||||
"-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "30px",
|
|
||||||
backgroundColor: c.bgTertiary,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "0 8px",
|
|
||||||
borderBottom: `1px solid ${c.bg}`,
|
|
||||||
fontSize: "12px",
|
|
||||||
color: c.textPrimary,
|
|
||||||
userSelect: "none",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{onBack && (
|
|
||||||
<button
|
|
||||||
onClick={onBack}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: c.textPrimary,
|
|
||||||
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 = c.border;
|
|
||||||
e.currentTarget.style.color = "#fff";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
e.currentTarget.style.color = c.textPrimary;
|
|
||||||
}}
|
|
||||||
title="Go back"
|
|
||||||
>
|
|
||||||
<MdArrowBack size={14} />
|
|
||||||
<span>Back</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{!onBack && <div />}
|
|
||||||
<span style={{ fontWeight: 400 }}>
|
|
||||||
{activeFile
|
|
||||||
? `${activeFile.name}${activeFile.dirty ? " •" : ""} - `
|
|
||||||
: ""}
|
|
||||||
{files.name}
|
|
||||||
</span>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
|
||||||
{activeFile?.dirty && (
|
|
||||||
<button
|
|
||||||
onClick={saveActiveFile}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: c.textPrimary,
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "11px",
|
|
||||||
padding: "4px 8px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
}}
|
|
||||||
title="Сохранить (Ctrl+S)"
|
|
||||||
>
|
|
||||||
Сохранить
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</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,138 +0,0 @@
|
|||||||
import { apiClient } from "@/shared/api/axios.instance";
|
|
||||||
import type { Interpreter } from "../types";
|
|
||||||
|
|
||||||
export interface ScriptNodeDto {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
type: "file" | "folder";
|
|
||||||
content?: string;
|
|
||||||
children?: string[];
|
|
||||||
interpreter_id?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ScriptResponse {
|
|
||||||
id: number;
|
|
||||||
content: string;
|
|
||||||
interpreter_id: number;
|
|
||||||
path: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateScriptPayload {
|
|
||||||
content: string;
|
|
||||||
interpreter_id: number;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateScriptPayload {
|
|
||||||
content: string;
|
|
||||||
interpreter_id: number;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RunScriptPayload {
|
|
||||||
stdin?: string;
|
|
||||||
token: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RunScriptResponse {
|
|
||||||
command: string[];
|
|
||||||
id: number;
|
|
||||||
wait_url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateInterpreterPayload {
|
|
||||||
argv: string[];
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JobWaitResponse {
|
|
||||||
command: string[];
|
|
||||||
id: number;
|
|
||||||
status: number;
|
|
||||||
stderr: string;
|
|
||||||
stdin: string;
|
|
||||||
stdout: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// apiClient уже имеет интерсептор для Authorization header
|
|
||||||
export const scriptsApi = {
|
|
||||||
getInterpreters: async (): Promise<Interpreter[]> => {
|
|
||||||
const res = await apiClient.get<Interpreter[]>("/scripts/interpreters");
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
getTree: async (): Promise<ScriptNodeDto[]> => {
|
|
||||||
const res = await apiClient.get<ScriptNodeDto[]>("/scripts/tree");
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
createScript: async (
|
|
||||||
payload: CreateScriptPayload,
|
|
||||||
): Promise<ScriptResponse> => {
|
|
||||||
const res = await apiClient.post<ScriptResponse>("/scripts", payload);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
updateScript: async (
|
|
||||||
id: number,
|
|
||||||
payload: UpdateScriptPayload,
|
|
||||||
): Promise<ScriptResponse> => {
|
|
||||||
const res = await apiClient.put<ScriptResponse>(`/scripts/${id}`, payload);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteScript: async (id: number): Promise<void> => {
|
|
||||||
await apiClient.delete(`/scripts/${id}`);
|
|
||||||
},
|
|
||||||
|
|
||||||
createFolder: async (path: string): Promise<{ path: string }> => {
|
|
||||||
const res = await apiClient.post<{ path: string }>("/scripts/folder", {
|
|
||||||
path,
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteFolder: async (path: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/scripts/folder`, { data: { path } });
|
|
||||||
},
|
|
||||||
|
|
||||||
rename: async (payload: {
|
|
||||||
old_path: string;
|
|
||||||
new_path: string;
|
|
||||||
}): Promise<{ path: string }> => {
|
|
||||||
const res = await apiClient.post<{ path: string }>(
|
|
||||||
"/scripts/rename",
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
runScript: async (
|
|
||||||
id: number,
|
|
||||||
payload: RunScriptPayload,
|
|
||||||
): Promise<RunScriptResponse> => {
|
|
||||||
const res = await apiClient.post<RunScriptResponse>(
|
|
||||||
`/scripts/${id}/run`,
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
waitJob: async (id: number): Promise<JobWaitResponse> => {
|
|
||||||
const res = await apiClient.post<JobWaitResponse>(`/jobs/${id}/wait`);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
|
|
||||||
createInterpreter: async (
|
|
||||||
payload: CreateInterpreterPayload,
|
|
||||||
): Promise<Interpreter> => {
|
|
||||||
const res = await apiClient.post<Interpreter>(
|
|
||||||
"/scripts/interpreters",
|
|
||||||
payload,
|
|
||||||
);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
import React, { useState, useRef } from "react";
|
|
||||||
import { MdClose, MdAdd } from "react-icons/md";
|
|
||||||
import { scriptsApi } from "../api/scripts.api";
|
|
||||||
import type { CreateInterpreterPayload } from "../api/scripts.api";
|
|
||||||
|
|
||||||
interface AddInterpreterModalProps {
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const AddInterpreterModal: React.FC<AddInterpreterModalProps> = ({
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
}) => {
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [label, setLabel] = useState("");
|
|
||||||
const [argv, setArgv] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const nameRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
nameRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!name.trim() || !label.trim()) {
|
|
||||||
setError("Name and Label are required");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const payload: CreateInterpreterPayload = {
|
|
||||||
name: name.trim(),
|
|
||||||
label: label.trim(),
|
|
||||||
argv: argv
|
|
||||||
.split(" ")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean),
|
|
||||||
};
|
|
||||||
|
|
||||||
await scriptsApi.createInterpreter(payload);
|
|
||||||
onSuccess();
|
|
||||||
onClose();
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error("Failed to create interpreter:", e);
|
|
||||||
setError(e?.response?.data?.detail || "Failed to create interpreter");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: 2000,
|
|
||||||
}}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
width: "420px",
|
|
||||||
maxWidth: "90vw",
|
|
||||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "16px 20px",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add Interpreter
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "4px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdClose size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Form */}
|
|
||||||
<form onSubmit={handleSubmit} style={{ padding: "20px" }}>
|
|
||||||
{/* Name */}
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
fontSize: "12px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Name <span style={{ color: "#f44747" }}>*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
ref={nameRef}
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="Python, Node.js, etc."
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
outline: "none",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Label */}
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
fontSize: "12px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Label <span style={{ color: "#f44747" }}>*</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={label}
|
|
||||||
onChange={(e) => setLabel(e.target.value)}
|
|
||||||
placeholder="python3, node, etc."
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
outline: "none",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Args */}
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
fontSize: "12px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Arguments <span style={{ color: "#858585" }}>(optional)</span>
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={argv}
|
|
||||||
onChange={(e) => setArgv(e.target.value)}
|
|
||||||
placeholder="-u -O (space separated)"
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
outline: "none",
|
|
||||||
boxSizing: "border-box",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px",
|
|
||||||
backgroundColor: "rgba(244, 71, 71, 0.1)",
|
|
||||||
border: "1px solid #f44747",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#f44747",
|
|
||||||
fontSize: "12px",
|
|
||||||
marginBottom: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Submit button */}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "10px",
|
|
||||||
backgroundColor: loading ? "#555" : "#0e639c",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#ffffff",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: loading ? "not-allowed" : "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
animation: "spin 1s linear infinite",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⏳
|
|
||||||
</span>
|
|
||||||
Creating...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MdAdd size={16} />
|
|
||||||
Add Interpreter
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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,345 +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();
|
|
||||||
// Загружаем интерпретаторы перед открытием меню
|
|
||||||
if (store.interpreters.length === 0) {
|
|
||||||
store.fetchInterpreters();
|
|
||||||
}
|
|
||||||
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 });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Загружаем интерпретаторы при монтировании компонента
|
|
||||||
useEffect(() => {
|
|
||||||
if (store.interpreters.length === 0) {
|
|
||||||
store.fetchInterpreters();
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const filteredFiles = store.searchQuery
|
|
||||||
? (files.children || [])
|
|
||||||
.map((child) => filterTree(child, store.searchQuery))
|
|
||||||
.filter((child): child is FileNode => child !== null)
|
|
||||||
: files.children || [];
|
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
{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.length > 0 ? (
|
|
||||||
filteredFiles.map((child, idx) => (
|
|
||||||
<FileTreeItem
|
|
||||||
key={idx}
|
|
||||||
node={child}
|
|
||||||
level={0}
|
|
||||||
onFileSelect={store.selectFile}
|
|
||||||
selectedFile={store.activeFile?.path || null}
|
|
||||||
onContextMenu={handleNodeContextMenu}
|
|
||||||
expandedFolders={store.expandedFolders}
|
|
||||||
onToggleFolder={store.toggleFolder}
|
|
||||||
onDelete={store.handleDeleteNode}
|
|
||||||
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={(value, interpreterId) => {
|
|
||||||
store.handleDialogConfirm(value, interpreterId);
|
|
||||||
}}
|
|
||||||
onCancel={() => store.setDialog(null)}
|
|
||||||
interpreters={
|
|
||||||
store.dialog.type === "newFile" ? store.interpreters : undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import type { FileNode } from "../types";
|
|
||||||
import { FilePickerItem } from "./FilePickerItem";
|
|
||||||
import { useFilePickerStore } from "../store/useFilePickerStore";
|
|
||||||
import { TerminalOutput } from "@/modules/terminal";
|
|
||||||
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
|
|
||||||
|
|
||||||
interface FilePickerProps {
|
|
||||||
files: FileNode;
|
|
||||||
onRun?: (path: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FilePickerTree: React.FC<{
|
|
||||||
node: FileNode;
|
|
||||||
level: number;
|
|
||||||
onRun?: (path: string) => void;
|
|
||||||
}> = ({ node, level, onRun }) => {
|
|
||||||
const expandedFolders = useFilePickerStore((s) => s.expandedFolders);
|
|
||||||
const toggleFolder = useFilePickerStore((s) => s.toggleFolder);
|
|
||||||
|
|
||||||
const nodePath = node.path || node.name;
|
|
||||||
const isExpanded = expandedFolders.has(nodePath);
|
|
||||||
|
|
||||||
if (node.type === "file") {
|
|
||||||
return (
|
|
||||||
<FilePickerItem
|
|
||||||
name={node.name}
|
|
||||||
type="file"
|
|
||||||
path={nodePath}
|
|
||||||
level={level}
|
|
||||||
onRun={onRun}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FilePickerItem
|
|
||||||
name={node.name}
|
|
||||||
type="folder"
|
|
||||||
path={nodePath}
|
|
||||||
isExpanded={isExpanded}
|
|
||||||
level={level}
|
|
||||||
onToggleFolder={toggleFolder}
|
|
||||||
>
|
|
||||||
{node.children?.map((child, idx) => (
|
|
||||||
<FilePickerTree
|
|
||||||
key={idx}
|
|
||||||
node={child}
|
|
||||||
level={level + 1}
|
|
||||||
onRun={onRun}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</FilePickerItem>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const FilePicker: React.FC<FilePickerProps> = ({ files, onRun }) => {
|
|
||||||
const terminalOpen = useTerminalStore((s) => s.isOpen);
|
|
||||||
const jobs = useTerminalStore((s) => s.jobs);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "100%",
|
|
||||||
overflowY: "auto",
|
|
||||||
backgroundColor: "var(--bg-primary)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Terminal — сверху, над списком файлов */}
|
|
||||||
{terminalOpen && jobs.length > 0 && (
|
|
||||||
<div style={{ height: 250 }}>
|
|
||||||
<TerminalOutput />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(files.children || []).map((child, idx) => (
|
|
||||||
<FilePickerTree key={idx} node={child} level={0} onRun={onRun} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
import {
|
|
||||||
FiChevronRight,
|
|
||||||
FiChevronDown,
|
|
||||||
FiFile,
|
|
||||||
FiFolder,
|
|
||||||
FiPlay,
|
|
||||||
} from "react-icons/fi";
|
|
||||||
|
|
||||||
interface FilePickerItemProps {
|
|
||||||
name: string;
|
|
||||||
type: "file" | "folder";
|
|
||||||
path: string;
|
|
||||||
isExpanded?: boolean;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
level: number;
|
|
||||||
onToggleSelect?: (path: string) => void;
|
|
||||||
onToggleFolder?: (path: string) => void;
|
|
||||||
onRun?: (path: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const FilePickerItem: React.FC<FilePickerItemProps> = ({
|
|
||||||
name,
|
|
||||||
type,
|
|
||||||
path,
|
|
||||||
isExpanded,
|
|
||||||
children,
|
|
||||||
level,
|
|
||||||
onToggleSelect,
|
|
||||||
onToggleFolder,
|
|
||||||
onRun,
|
|
||||||
}) => {
|
|
||||||
const isFolder = type === "folder";
|
|
||||||
const extension = name.includes(".")
|
|
||||||
? name.split(".").pop()?.toUpperCase()
|
|
||||||
: "";
|
|
||||||
const paddingLeft = 12 + level * 20;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
paddingLeft: `${paddingLeft}px`,
|
|
||||||
paddingRight: "12px",
|
|
||||||
height: "36px",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
cursor: "pointer",
|
|
||||||
transition: "background-color 0.1s",
|
|
||||||
gap: "8px",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (isFolder && onToggleFolder) {
|
|
||||||
onToggleFolder(path);
|
|
||||||
} else if (!isFolder && onToggleSelect) {
|
|
||||||
onToggleSelect(path);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "var(--bg-secondary)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Folder expand icon */}
|
|
||||||
{isFolder && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
display: "flex",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isExpanded ? (
|
|
||||||
<FiChevronDown size={14} />
|
|
||||||
) : (
|
|
||||||
<FiChevronRight size={14} />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File/Folder icon */}
|
|
||||||
<span style={{ display: "flex", flexShrink: 0 }}>
|
|
||||||
{isFolder ? (
|
|
||||||
<FiFolder size={15} color="var(--accent)" />
|
|
||||||
) : (
|
|
||||||
<FiFile size={15} color="var(--text-secondary)" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
{/* Extension badge — только у файлов */}
|
|
||||||
{!isFolder && extension && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
fontSize: "11px",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
padding: "2px 6px",
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
borderRadius: "3px",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{extension}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Run button — только у файлов */}
|
|
||||||
{!isFolder && onRun && (
|
|
||||||
<button
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
padding: "4px",
|
|
||||||
backgroundColor: "transparent",
|
|
||||||
border: "1px solid transparent",
|
|
||||||
borderRadius: "3px",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
cursor: "pointer",
|
|
||||||
flexShrink: 0,
|
|
||||||
transition: "all 0.15s",
|
|
||||||
}}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onRun(path);
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "#238636";
|
|
||||||
e.currentTarget.style.color = "#ffffff";
|
|
||||||
e.currentTarget.style.borderColor = "#2ea043";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.backgroundColor = "transparent";
|
|
||||||
e.currentTarget.style.color = "var(--text-secondary)";
|
|
||||||
e.currentTarget.style.borderColor = "transparent";
|
|
||||||
}}
|
|
||||||
title="Run script"
|
|
||||||
>
|
|
||||||
<FiPlay size={12} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Children */}
|
|
||||||
{isFolder && isExpanded && children}
|
|
||||||
</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,169 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
|
||||||
import type { Interpreter } from "../types";
|
|
||||||
|
|
||||||
interface InputDialogProps {
|
|
||||||
title: string;
|
|
||||||
initialValue?: string;
|
|
||||||
onConfirm: (value: string, interpreterId?: number) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
interpreters?: Interpreter[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InputDialog: React.FC<InputDialogProps> = ({
|
|
||||||
title,
|
|
||||||
initialValue = "",
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
interpreters,
|
|
||||||
}) => {
|
|
||||||
const [value, setValue] = useState(initialValue);
|
|
||||||
const [interpreterId, setInterpreterId] = useState<number | undefined>(
|
|
||||||
interpreters?.[0]?.id,
|
|
||||||
);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
inputRef.current?.select();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const showInterpreterDropdown = interpreters && interpreters.length > 0;
|
|
||||||
|
|
||||||
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 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(), interpreterId)
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "10px",
|
|
||||||
backgroundColor: "#3c3c3c",
|
|
||||||
border: "1px solid #3e3e42",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "#ccc",
|
|
||||||
fontSize: "14px",
|
|
||||||
marginBottom: showInterpreterDropdown ? "12px" : "20px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Interpreter dropdown */}
|
|
||||||
{showInterpreterDropdown && (
|
|
||||||
<div style={{ marginBottom: "20px" }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
fontSize: "12px",
|
|
||||||
color: "#858585",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Interpreter
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={interpreterId}
|
|
||||||
onChange={(e) => setInterpreterId(Number(e.target.value))}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "10px",
|
|
||||||
backgroundColor: "#3c3c3c",
|
|
||||||
border: "1px solid #3e3e42",
|
|
||||||
borderRadius: "6px",
|
|
||||||
color: "#ccc",
|
|
||||||
fontSize: "14px",
|
|
||||||
outline: "none",
|
|
||||||
cursor: "pointer",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{interpreters.map((interp) => (
|
|
||||||
<option key={interp.id} value={interp.id}>
|
|
||||||
{interp.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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(), interpreterId)
|
|
||||||
}
|
|
||||||
style={{
|
|
||||||
padding: "6px 16px",
|
|
||||||
backgroundColor: "#0e639c",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#fff",
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "12px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
OK
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect } from "react";
|
|
||||||
import { MdClose } from "react-icons/md";
|
|
||||||
import { scriptsApi } from "../api/scripts.api";
|
|
||||||
import { useTerminalStore } from "@/modules/terminal/store/useTerminalStore";
|
|
||||||
import { useAgentStore } from "@/app/providers/layout/store/agent.store";
|
|
||||||
|
|
||||||
interface RunScriptModalProps {
|
|
||||||
scriptPath: string;
|
|
||||||
scriptId: number;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RunScriptModal: React.FC<RunScriptModalProps> = ({
|
|
||||||
scriptPath,
|
|
||||||
scriptId,
|
|
||||||
onClose,
|
|
||||||
}) => {
|
|
||||||
const [selectedAgentIdx, setSelectedAgentIdx] = useState(0);
|
|
||||||
const [stdinValue, setStdinValue] = useState("");
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const inputRef = useRef<HTMLSelectElement>(null);
|
|
||||||
|
|
||||||
const agents = useAgentStore((s) => s.agents);
|
|
||||||
const addJob = useTerminalStore((s) => s.addJob);
|
|
||||||
const openTerminal = useTerminalStore((s) => s.openTerminal);
|
|
||||||
|
|
||||||
const selectedAgent = agents[selectedAgentIdx];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
inputRef.current?.focus();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleRun = async () => {
|
|
||||||
if (!selectedAgent) {
|
|
||||||
setError("No agents available");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1. Запускаем скрипт
|
|
||||||
const runResult = await scriptsApi.runScript(scriptId, {
|
|
||||||
stdin: stdinValue,
|
|
||||||
token: selectedAgent.token,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Добавляем джоб в терминал
|
|
||||||
addJob({
|
|
||||||
id: runResult.id,
|
|
||||||
scriptPath,
|
|
||||||
command: runResult.command,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Открываем терминал
|
|
||||||
openTerminal();
|
|
||||||
|
|
||||||
// 4. Ждём завершения по id
|
|
||||||
const jobResult = await scriptsApi.waitJob(runResult.id);
|
|
||||||
|
|
||||||
// 5. Обновляем существующий джоб (не создаём новый!)
|
|
||||||
const terminalStore = useTerminalStore.getState();
|
|
||||||
terminalStore.updateJob(runResult.id, {
|
|
||||||
command: jobResult.command,
|
|
||||||
stdin: jobResult.stdin,
|
|
||||||
status: jobResult.status,
|
|
||||||
stdout: jobResult.stdout,
|
|
||||||
stderr: jobResult.stderr,
|
|
||||||
isRunning: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
onClose();
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error("Failed to run script:", e);
|
|
||||||
setError(e?.response?.data?.detail || "Failed to run script");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
zIndex: 2000,
|
|
||||||
}}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--card-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "8px",
|
|
||||||
width: "420px",
|
|
||||||
maxWidth: "90vw",
|
|
||||||
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.4)",
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
padding: "16px 20px",
|
|
||||||
borderBottom: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<h3
|
|
||||||
style={{
|
|
||||||
margin: 0,
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Run Script
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
style={{
|
|
||||||
background: "transparent",
|
|
||||||
border: "none",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "4px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
display: "flex",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdClose size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div style={{ padding: "20px" }}>
|
|
||||||
{/* Script path */}
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
fontSize: "12px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Script
|
|
||||||
</label>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px",
|
|
||||||
backgroundColor: "var(--bg-secondary)",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{scriptPath}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Agent selector */}
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
fontSize: "12px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Agent <span style={{ color: "#f44747" }}>*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
ref={inputRef}
|
|
||||||
value={selectedAgentIdx}
|
|
||||||
onChange={(e) => setSelectedAgentIdx(Number(e.target.value))}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{agents.length === 0 && (
|
|
||||||
<option value="">No agents available</option>
|
|
||||||
)}
|
|
||||||
{agents.map((agent, idx) => (
|
|
||||||
<option key={agent.label} value={idx}>
|
|
||||||
{agent.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stdin (optional) */}
|
|
||||||
<div style={{ marginBottom: "16px" }}>
|
|
||||||
<label
|
|
||||||
style={{
|
|
||||||
display: "block",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
fontSize: "12px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Stdin <span style={{ color: "#858585" }}>(optional)</span>
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={stdinValue}
|
|
||||||
onChange={(e) => setStdinValue(e.target.value)}
|
|
||||||
placeholder="Enter input data..."
|
|
||||||
rows={4}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
backgroundColor: "var(--input-bg)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
resize: "vertical",
|
|
||||||
outline: "none",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Error */}
|
|
||||||
{error && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "8px 12px",
|
|
||||||
backgroundColor: "rgba(244, 71, 71, 0.1)",
|
|
||||||
border: "1px solid #f44747",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#f44747",
|
|
||||||
fontSize: "12px",
|
|
||||||
marginBottom: "16px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Run button */}
|
|
||||||
<button
|
|
||||||
onClick={handleRun}
|
|
||||||
disabled={loading || !selectedAgent}
|
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
padding: "10px",
|
|
||||||
backgroundColor: loading || !selectedAgent ? "#555" : "#0e639c",
|
|
||||||
border: "none",
|
|
||||||
borderRadius: "4px",
|
|
||||||
color: "#ffffff",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: 500,
|
|
||||||
cursor: loading || !selectedAgent ? "not-allowed" : "pointer",
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
gap: "8px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
animation: "spin 1s linear infinite",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
⏳
|
|
||||||
</span>
|
|
||||||
Running...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>▶ Run</>
|
|
||||||
)}
|
|
||||||
</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,207 +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>
|
|
||||||
{file.dirty && (
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: "8px",
|
|
||||||
height: "8px",
|
|
||||||
borderRadius: "50%",
|
|
||||||
backgroundColor: "#fbbf24",
|
|
||||||
marginLeft: "-4px",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<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,10 +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";
|
|
||||||
export { FilePickerItem } from "./FilePickerItem";
|
|
||||||
export { FilePicker } from "./FilePicker";
|
|
||||||
@@ -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,5 +0,0 @@
|
|||||||
export { IDE } from "./IDE";
|
|
||||||
export { FilePicker } from "./components/FilePicker";
|
|
||||||
export { useIDEStore, initialFiles } from "./store/useIDEStore";
|
|
||||||
export { useFilePickerStore } from "./store/useFilePickerStore";
|
|
||||||
export type { FileNode } from "./types";
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
interface FilePickerState {
|
|
||||||
selectedPaths: Set<string>;
|
|
||||||
expandedFolders: Set<string>;
|
|
||||||
|
|
||||||
toggleSelection: (path: string) => void;
|
|
||||||
selectAll: (paths: string[]) => void;
|
|
||||||
clearSelection: () => void;
|
|
||||||
toggleFolder: (path: string) => void;
|
|
||||||
getSelectedPaths: () => string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useFilePickerStore = create<FilePickerState>((set, get) => ({
|
|
||||||
selectedPaths: new Set(),
|
|
||||||
expandedFolders: new Set(),
|
|
||||||
|
|
||||||
toggleSelection: (path: string) => {
|
|
||||||
set((state) => {
|
|
||||||
const newSet = new Set(state.selectedPaths);
|
|
||||||
if (newSet.has(path)) {
|
|
||||||
newSet.delete(path);
|
|
||||||
} else {
|
|
||||||
newSet.add(path);
|
|
||||||
}
|
|
||||||
return { selectedPaths: newSet };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
selectAll: (paths: string[]) => {
|
|
||||||
set((state) => {
|
|
||||||
const newSet = new Set(state.selectedPaths);
|
|
||||||
paths.forEach((p) => newSet.add(p));
|
|
||||||
return { selectedPaths: newSet };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clearSelection: () => {
|
|
||||||
set({ selectedPaths: new Set() });
|
|
||||||
},
|
|
||||||
|
|
||||||
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 };
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
getSelectedPaths: () => {
|
|
||||||
return Array.from(get().selectedPaths);
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
@@ -1,641 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
import type { FileNode, Interpreter, DialogState } from "../types";
|
|
||||||
import {
|
|
||||||
addPaths,
|
|
||||||
getAllFolderPaths,
|
|
||||||
findNode,
|
|
||||||
deleteNode,
|
|
||||||
addNode,
|
|
||||||
renameNode,
|
|
||||||
} from "../helpers/fileTree";
|
|
||||||
import { scriptsApi } from "../api/scripts.api";
|
|
||||||
|
|
||||||
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 IDEFileNode extends FileNode {
|
|
||||||
dirty?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IDEState {
|
|
||||||
// Файловая система
|
|
||||||
files: FileNode | null;
|
|
||||||
openFiles: IDEFileNode[];
|
|
||||||
activeFile: IDEFileNode | null;
|
|
||||||
expandedFolders: Set<string>;
|
|
||||||
searchQuery: string;
|
|
||||||
showSearch: boolean;
|
|
||||||
isInitialized: boolean;
|
|
||||||
interpreters: Interpreter[];
|
|
||||||
|
|
||||||
// Диалоги и контекстные меню
|
|
||||||
contextMenu: { x: number; y: number; node: FileNode | null } | null;
|
|
||||||
dialog: DialogState | null;
|
|
||||||
tabContextMenu: { x: number; y: number; file: FileNode } | null;
|
|
||||||
|
|
||||||
// Действия с файлами
|
|
||||||
selectFile: (node: FileNode) => void;
|
|
||||||
updateFileContent: (content: string) => void;
|
|
||||||
saveActiveFile: () => Promise<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;
|
|
||||||
|
|
||||||
// Интерпретаторы
|
|
||||||
fetchInterpreters: () => Promise<void>;
|
|
||||||
|
|
||||||
// API методы
|
|
||||||
fetchTree: () => Promise<void>;
|
|
||||||
createScript: (payload: {
|
|
||||||
content: string;
|
|
||||||
interpreter_id: number;
|
|
||||||
path: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
createFolder: (path: string) => Promise<void>;
|
|
||||||
updateScript: (
|
|
||||||
id: number,
|
|
||||||
payload: { content: string; interpreter_id: number; path: string },
|
|
||||||
) => Promise<void>;
|
|
||||||
deleteScript: (id: number) => Promise<void>;
|
|
||||||
deleteFolder: (payload: { path: string }) => Promise<void>;
|
|
||||||
saveActiveFile: () => Promise<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, interpreterId?: number) => Promise<void>;
|
|
||||||
handleDeleteNode: (node: FileNode) => Promise<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,
|
|
||||||
interpreters: [],
|
|
||||||
|
|
||||||
// Инициализация
|
|
||||||
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, files } = get();
|
|
||||||
// Берём актуальную версию из дерева файлов
|
|
||||||
const latestFile = files ? findNode(files, node.path || "") : null;
|
|
||||||
const fileToOpen =
|
|
||||||
latestFile && latestFile.type === "file" ? latestFile : node;
|
|
||||||
|
|
||||||
if (!openFiles.find((f) => f.path === fileToOpen.path)) {
|
|
||||||
set((state) => ({ openFiles: [...state.openFiles, fileToOpen] }));
|
|
||||||
}
|
|
||||||
set({ activeFile: fileToOpen });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Обновление содержимого файла
|
|
||||||
updateFileContent: (content: string) => {
|
|
||||||
const { activeFile, files } = get();
|
|
||||||
if (activeFile && files) {
|
|
||||||
const updatedFile = { ...activeFile, content, dirty: true };
|
|
||||||
set({ activeFile: updatedFile });
|
|
||||||
set((state) => ({
|
|
||||||
openFiles: state.openFiles.map((f) =>
|
|
||||||
f.path === activeFile.path ? updatedFile : f,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Обновляем также в дереве файлов
|
|
||||||
const updateFileInTree = (node: FileNode): FileNode => {
|
|
||||||
if (node.path === activeFile.path) {
|
|
||||||
return updatedFile;
|
|
||||||
}
|
|
||||||
if (node.children) {
|
|
||||||
return {
|
|
||||||
...node,
|
|
||||||
children: node.children.map((child) => updateFileInTree(child)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
set({ files: updateFileInTree(files) });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Закрытие файла
|
|
||||||
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: "",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Интерпретаторы
|
|
||||||
fetchInterpreters: async () => {
|
|
||||||
try {
|
|
||||||
const interpreters = await scriptsApi.getInterpreters();
|
|
||||||
set({ interpreters });
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to fetch interpreters:", e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// API: загрузка дерева с сервера
|
|
||||||
fetchTree: async () => {
|
|
||||||
try {
|
|
||||||
const data = await scriptsApi.getTree();
|
|
||||||
const { expandedFolders } = get();
|
|
||||||
|
|
||||||
const convertItem = (item: any): FileNode => {
|
|
||||||
const node: FileNode = {
|
|
||||||
id: item.id,
|
|
||||||
name: item.name,
|
|
||||||
type: item.type === "folder" ? "folder" : "file",
|
|
||||||
content: item.content || "",
|
|
||||||
path: item.name,
|
|
||||||
interpreter_id: item.interpreter_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (item.type === "folder") {
|
|
||||||
node.children = [];
|
|
||||||
if (item.children && Array.isArray(item.children)) {
|
|
||||||
node.children = item.children.map((child: any) => {
|
|
||||||
const childNode = convertItem(child);
|
|
||||||
childNode.path = `${item.name}/${child.name}`;
|
|
||||||
return childNode;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
|
|
||||||
const roots = data.map((item) => convertItem(item));
|
|
||||||
|
|
||||||
set({
|
|
||||||
files: {
|
|
||||||
name: "scripts",
|
|
||||||
type: "folder",
|
|
||||||
children: roots,
|
|
||||||
},
|
|
||||||
expandedFolders,
|
|
||||||
isInitialized: true,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to fetch tree:", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// API: создание скрипта
|
|
||||||
createScript: async (payload) => {
|
|
||||||
try {
|
|
||||||
await scriptsApi.createScript(payload);
|
|
||||||
await get().fetchTree();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to create script:", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// API: создание папки
|
|
||||||
createFolder: async (path: string) => {
|
|
||||||
try {
|
|
||||||
await scriptsApi.createFolder(path);
|
|
||||||
await get().fetchTree();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to create folder:", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// API: удаление папки
|
|
||||||
deleteFolder: async ({ path }: { path: string }) => {
|
|
||||||
try {
|
|
||||||
const { openFiles } = get();
|
|
||||||
|
|
||||||
// Закрываем все файлы, которые находятся в удаляемой папке
|
|
||||||
const folderPathPrefix = path.endsWith("/") ? path : `${path}/`;
|
|
||||||
const filesToClose = openFiles.filter(
|
|
||||||
(f) => f.path === path || f.path?.startsWith(folderPathPrefix),
|
|
||||||
);
|
|
||||||
filesToClose.forEach((f) => get().closeFile(f));
|
|
||||||
|
|
||||||
await scriptsApi.deleteFolder(path);
|
|
||||||
await get().fetchTree();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to delete folder:", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// API: обновление скрипта
|
|
||||||
updateScript: async (id, payload) => {
|
|
||||||
try {
|
|
||||||
await scriptsApi.updateScript(id, payload);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to update script:", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// API: удаление скрипта
|
|
||||||
deleteScript: async (id) => {
|
|
||||||
try {
|
|
||||||
await scriptsApi.deleteScript(id);
|
|
||||||
await get().fetchTree();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to delete script:", e);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// API: сохранение активного файла
|
|
||||||
saveActiveFile: async () => {
|
|
||||||
const { activeFile } = get();
|
|
||||||
if (!activeFile || !activeFile.id) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scriptsApi.updateScript(activeFile.id, {
|
|
||||||
content: activeFile.content || "",
|
|
||||||
interpreter_id: activeFile.interpreter_id || 0,
|
|
||||||
path: activeFile.path || "",
|
|
||||||
});
|
|
||||||
set((state) => ({
|
|
||||||
activeFile: state.activeFile
|
|
||||||
? { ...state.activeFile, dirty: false }
|
|
||||||
: null,
|
|
||||||
openFiles: state.openFiles.map((f) =>
|
|
||||||
f.path === state.activeFile?.path ? { ...f, dirty: false } : f,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to save file:", e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// Поиск
|
|
||||||
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: async (value: string, interpreterId?: number) => {
|
|
||||||
const { dialog, files, 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 oldPath = dialog.node.path || dialog.node.name;
|
|
||||||
const newPath = parentPath ? `${parentPath}/${value}` : value;
|
|
||||||
|
|
||||||
// Сохраняем раскрытые папки
|
|
||||||
const savedExpandedFolders = new Set(get().expandedFolders);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await scriptsApi.rename({ old_path: oldPath, new_path: newPath });
|
|
||||||
await get().fetchTree();
|
|
||||||
|
|
||||||
// Восстанавливаем раскрытые папки
|
|
||||||
set({ expandedFolders: savedExpandedFolders });
|
|
||||||
|
|
||||||
// Раскрываем родительскую цепочку
|
|
||||||
const allParentPaths: string[] = [];
|
|
||||||
let current = parentPath;
|
|
||||||
while (current) {
|
|
||||||
allParentPaths.push(current);
|
|
||||||
const parts = current.split("/");
|
|
||||||
parts.pop();
|
|
||||||
current = parts.join("/");
|
|
||||||
}
|
|
||||||
autoExpandPaths(new Set(allParentPaths));
|
|
||||||
|
|
||||||
// Если переименованный файл был открыт — обновим его в openFiles
|
|
||||||
const { openFiles, activeFile } = get();
|
|
||||||
const updatedOpenFiles = openFiles.map((f) =>
|
|
||||||
f.path === oldPath ? { ...f, name: value, path: newPath } : f,
|
|
||||||
);
|
|
||||||
set({ openFiles: updatedOpenFiles });
|
|
||||||
|
|
||||||
if (activeFile?.path === oldPath) {
|
|
||||||
set({ activeFile: { ...activeFile, name: value, path: newPath } });
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to rename:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ dialog: null });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Определяем родительский путь
|
|
||||||
let parentPath: string;
|
|
||||||
if (!dialog.node) {
|
|
||||||
parentPath = "";
|
|
||||||
} 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("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем наличие расширения
|
|
||||||
const hasExtension =
|
|
||||||
value.includes(".") && value.split(".").pop() !== value;
|
|
||||||
let finalName = value;
|
|
||||||
let isFile = false;
|
|
||||||
|
|
||||||
// Если диалог создания файла
|
|
||||||
if (dialog.type === "newFile") {
|
|
||||||
isFile = true;
|
|
||||||
// Если нет расширения — добавляем .txt
|
|
||||||
if (!hasExtension) {
|
|
||||||
finalName = `${value}.txt`;
|
|
||||||
}
|
|
||||||
} else if (dialog.type === "newFolder") {
|
|
||||||
// Если диалог создания папки — но имя с расширением, считаем файлом
|
|
||||||
if (hasExtension) {
|
|
||||||
isFile = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const fullPath = parentPath ? `${parentPath}/${finalName}` : finalName;
|
|
||||||
|
|
||||||
// Сохраняем раскрытые папки ДО перезагрузки дерева
|
|
||||||
const savedExpandedFolders = new Set(get().expandedFolders);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Создание папки
|
|
||||||
if (dialog.type === "newFolder" && !isFile) {
|
|
||||||
await scriptsApi.createFolder(fullPath);
|
|
||||||
await get().fetchTree();
|
|
||||||
|
|
||||||
// Восстанавливаем раскрытые папки
|
|
||||||
set({ expandedFolders: savedExpandedFolders });
|
|
||||||
|
|
||||||
// Собираем все пути от корня до родительской папки
|
|
||||||
const allParentPaths: string[] = [];
|
|
||||||
let current = parentPath;
|
|
||||||
while (current) {
|
|
||||||
allParentPaths.push(current);
|
|
||||||
const parts = current.split("/");
|
|
||||||
parts.pop();
|
|
||||||
current = parts.join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Раскрываем родительскую цепочку
|
|
||||||
autoExpandPaths(new Set(allParentPaths));
|
|
||||||
} else {
|
|
||||||
// Создание файла
|
|
||||||
const result = await scriptsApi.createScript({
|
|
||||||
content: "",
|
|
||||||
interpreter_id: interpreterId || 0,
|
|
||||||
path: fullPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
await get().fetchTree();
|
|
||||||
|
|
||||||
// Восстанавливаем раскрытые папки
|
|
||||||
set({ expandedFolders: savedExpandedFolders });
|
|
||||||
|
|
||||||
// Собираем все пути от корня до родительской папки
|
|
||||||
const allParentPaths: string[] = [];
|
|
||||||
let current = parentPath;
|
|
||||||
while (current) {
|
|
||||||
allParentPaths.push(current);
|
|
||||||
const parts = current.split("/");
|
|
||||||
parts.pop();
|
|
||||||
current = parts.join("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Раскрываем родительскую цепочку
|
|
||||||
autoExpandPaths(new Set(allParentPaths));
|
|
||||||
|
|
||||||
const createdNode: FileNode = {
|
|
||||||
id: result.id,
|
|
||||||
name: finalName,
|
|
||||||
type: "file",
|
|
||||||
content: result.content,
|
|
||||||
path: result.path,
|
|
||||||
interpreter_id: result.interpreter_id,
|
|
||||||
};
|
|
||||||
get().selectFile(createdNode);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to create:", e);
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ dialog: null });
|
|
||||||
},
|
|
||||||
|
|
||||||
// Удаление узла
|
|
||||||
handleDeleteNode: async (node: FileNode) => {
|
|
||||||
const { files } = get();
|
|
||||||
const isRootNode = node.path === files?.path;
|
|
||||||
if (isRootNode) {
|
|
||||||
get().deleteRoot();
|
|
||||||
} else if (window.confirm(`Delete "${node.name}"?`)) {
|
|
||||||
try {
|
|
||||||
if (node.type === "folder") {
|
|
||||||
await get().deleteFolder({ path: node.path || node.name });
|
|
||||||
} else if (node.id) {
|
|
||||||
await get().deleteScript(node.id);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to delete:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user