feat: 新增「部署客户端」日志采集器模块

三种采集器(host-promtail / k8s-daemonset / k8s-fluentbit-sidecar),每种关联已部署 Loki:
- host-promtail:SSH 安装 Promtail(复用 deployBinary)
- k8s-daemonset:helm 安装 Promtail DaemonSet(复用 deployHelm)
- k8s-fluentbit-sidecar:kubectl patch 注入 Fluent Bit sidecar

含 Collector 数据模型、三个部署器、配置模板、CRUD API、前端页面。
附带 UI 优化:ErrorBoundary、部署卡片浏览器访问按钮、菜单名称调整。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Logan.LiandClaude committed 2026-06-29 19:26:41 +08:00
1 parent 2a93de600d
commit 673b2c60c8
17 files changed
+1442 -4

No files matched your search

+1 -1
View File
@@ -1 +1 @@
46832 93376
+122
View File
@@ -0,0 +1,122 @@
package controllers
import (
"lokideploy/models"
"lokideploy/services"
"lokideploy/utils"
"github.com/gin-gonic/gin"
)
type CollectorController struct {
service *services.CollectorService
}
func NewCollectorController() *CollectorController {
return &CollectorController{service: services.NewCollectorService()}
}
type CreateCollectorRequest struct {
Name string `json:"name" binding:"required"`
Type string `json:"type" binding:"required"`
LokiDeploymentID uint `json:"lokiDeploymentID" binding:"required"`
HostID *uint `json:"hostID"`
ClusterID *uint `json:"clusterID"`
TargetDeployment string `json:"targetDeployment"`
Namespace string `json:"namespace"`
LogPath string `json:"logPath"`
Labels string `json:"labels"`
}
func (ctrl *CollectorController) Create(c *gin.Context) {
var req CreateCollectorRequest
if !utils.BindAndValidate(c, &req) {
return
}
collector, err := ctrl.service.Create(&services.CreateCollectorRequest{
Name: req.Name, Type: req.Type, LokiDeploymentID: req.LokiDeploymentID,
HostID: req.HostID, ClusterID: req.ClusterID, TargetDeployment: req.TargetDeployment,
Namespace: req.Namespace, LogPath: req.LogPath, Labels: req.Labels,
})
if err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, models.SanitizedJSON(collector))
}
func (ctrl *CollectorController) Get(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
collector, err := ctrl.service.Get(uint(id))
if err != nil {
utils.NotFound(c, "采集器不存在")
return
}
utils.Success(c, models.SanitizedJSON(collector))
}
func (ctrl *CollectorController) List(c *gin.Context) {
page, pageSize := utils.Pagination(c)
collectors, total, err := ctrl.service.List(page, pageSize)
if err != nil {
utils.Error(c, 500, err.Error())
return
}
utils.Success(c, utils.PageData{
List: models.SanitizedJSON(collectors), Total: total, Page: page, PageSize: pageSize,
})
}
func (ctrl *CollectorController) Update(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
var updates map[string]interface{}
if !utils.BindAndValidate(c, &updates) {
return
}
if err := ctrl.service.Update(uint(id), updates); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "更新成功"})
}
func (ctrl *CollectorController) Delete(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
if err := ctrl.service.Delete(uint(id)); err != nil {
utils.Error(c, 400, err.Error())
return
}
utils.Success(c, gin.H{"message": "删除成功"})
}
func (ctrl *CollectorController) Install(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
go ctrl.service.Install(uint(id))
utils.Success(c, gin.H{"message": "已开始部署采集器"})
}
func (ctrl *CollectorController) Uninstall(c *gin.Context) {
id, err := utils.IntParam(c, "id")
if err != nil {
utils.BadRequest(c, "ID 无效")
return
}
go ctrl.service.Uninstall(uint(id))
utils.Success(c, gin.H{"message": "已开始卸载采集器"})
}
+66
View File
@@ -0,0 +1,66 @@
package models
import "time"
// Collector 日志采集器(部署客户端),关联一个已部署的 Loki 作为推送目标
type Collector struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // host-promtail | k8s-daemonset | k8s-fluentbit-sidecar
LokiDeploymentID uint `json:"lokiDeploymentID"`
LokiDeployment *LokiDeployment `json:"lokiDeployment,omitempty"`
RefType string `json:"refType"` // host | cluster
HostID *uint `json:"hostID,omitempty"`
Host *Host `json:"host,omitempty"`
ClusterID *uint `json:"clusterID,omitempty"`
Cluster *Cluster `json:"cluster,omitempty"`
TargetDeployment string `json:"targetDeployment"` // sidecar 专用:目标 deployment 名
Namespace string `json:"namespace"` // k8s 命名空间(默认 logging)
LogPath string `json:"logPath"` // 采集日志路径
Labels string `json:"labels"` // 额外标签(JSON)
Status string `json:"status"` // pending | active | error
StatusMessage string `json:"statusMessage"`
Config string `json:"config"` // 生成的配置快照
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (c *Collector) Sanitized() map[string]interface{} {
var loki map[string]interface{}
if c.LokiDeployment != nil {
loki = map[string]interface{}{
"id": c.LokiDeployment.ID,
"name": c.LokiDeployment.Name,
"endpoint": c.LokiDeployment.Endpoint,
"status": c.LokiDeployment.Status,
}
}
var host map[string]interface{}
if c.Host != nil {
host = c.Host.Sanitized()
}
var cluster map[string]interface{}
if c.Cluster != nil {
cluster = c.Cluster.Sanitized()
}
return map[string]interface{}{
"id": c.ID,
"name": c.Name,
"type": c.Type,
"lokiDeploymentID": c.LokiDeploymentID,
"lokiDeployment": loki,
"refType": c.RefType,
"hostID": c.HostID,
"host": host,
"clusterID": c.ClusterID,
"cluster": cluster,
"targetDeployment": c.TargetDeployment,
"namespace": c.Namespace,
"logPath": c.LogPath,
"labels": c.Labels,
"status": c.Status,
"statusMessage": c.StatusMessage,
"createdAt": c.CreatedAt,
"updatedAt": c.UpdatedAt,
}
}
+4
View File
@@ -15,6 +15,7 @@ type JsonDB struct {
Hosts []Host `json:"hosts"` Hosts []Host `json:"hosts"`
Storages []StorageConfig `json:"storages"` Storages []StorageConfig `json:"storages"`
Deployments []LokiDeployment `json:"deployments"` Deployments []LokiDeployment `json:"deployments"`
Collectors []Collector `json:"collectors"`
Logs []DeploymentLog `json:"logs"` Logs []DeploymentLog `json:"logs"`
Settings []SystemSetting `json:"settings"` Settings []SystemSetting `json:"settings"`
sequences map[string]uint sequences map[string]uint
@@ -37,6 +38,7 @@ func InitDB() error {
Hosts: []Host{}, Hosts: []Host{},
Storages: []StorageConfig{}, Storages: []StorageConfig{},
Deployments: []LokiDeployment{}, Deployments: []LokiDeployment{},
Collectors: []Collector{},
Logs: []DeploymentLog{}, Logs: []DeploymentLog{},
Settings: []SystemSetting{}, Settings: []SystemSetting{},
sequences: map[string]uint{ sequences: map[string]uint{
@@ -45,6 +47,7 @@ func InitDB() error {
"hosts": 0, "hosts": 0,
"storages": 0, "storages": 0,
"deployments": 0, "deployments": 0,
"collectors": 0,
"logs": 0, "logs": 0,
}, },
} }
@@ -57,6 +60,7 @@ func InitDB() error {
for _, h := range d.Hosts { if h.ID > d.sequences["hosts"] { d.sequences["hosts"] = h.ID } } for _, h := range d.Hosts { if h.ID > d.sequences["hosts"] { d.sequences["hosts"] = h.ID } }
for _, s := range d.Storages { if s.ID > d.sequences["storages"] { d.sequences["storages"] = s.ID } } for _, s := range d.Storages { if s.ID > d.sequences["storages"] { d.sequences["storages"] = s.ID } }
for _, de := range d.Deployments { if de.ID > d.sequences["deployments"] { d.sequences["deployments"] = de.ID } } for _, de := range d.Deployments { if de.ID > d.sequences["deployments"] { d.sequences["deployments"] = de.ID } }
for _, col := range d.Collectors { if col.ID > d.sequences["collectors"] { d.sequences["collectors"] = col.ID } }
for _, l := range d.Logs { if l.ID > d.sequences["logs"] { d.sequences["logs"] = l.ID } } for _, l := range d.Logs { if l.ID > d.sequences["logs"] { d.sequences["logs"] = l.ID } }
} }
} }
+13
View File
@@ -37,6 +37,13 @@ func SanitizedJSON(v any) any {
return nil return nil
} }
return val.Sanitized() return val.Sanitized()
case Collector:
return val.Sanitized()
case *Collector:
if val == nil {
return nil
}
return val.Sanitized()
// Slices // Slices
case []User: case []User:
result := make([]map[string]interface{}, len(val)) result := make([]map[string]interface{}, len(val))
@@ -62,6 +69,12 @@ func SanitizedJSON(v any) any {
result[i] = item.Sanitized() result[i] = item.Sanitized()
} }
return result return result
case []Collector:
result := make([]map[string]interface{}, len(val))
for i, item := range val {
result[i] = item.Sanitized()
}
return result
default: default:
return v return v
} }
+10
View File
@@ -28,6 +28,7 @@ func SetupRouter() *gin.Engine {
hostCtrl := controllers.NewHostController() hostCtrl := controllers.NewHostController()
storageCtrl := controllers.NewStorageController() storageCtrl := controllers.NewStorageController()
deploymentCtrl := controllers.NewDeploymentController() deploymentCtrl := controllers.NewDeploymentController()
collectorCtrl := controllers.NewCollectorController()
// API v1 // API v1
api := r.Group("/api/v1") api := r.Group("/api/v1")
@@ -83,6 +84,15 @@ func SetupRouter() *gin.Engine {
authorized.GET("/deployments/:id/logs", deploymentCtrl.GetLogs) authorized.GET("/deployments/:id/logs", deploymentCtrl.GetLogs)
authorized.GET("/deployments/stats", deploymentCtrl.GetStats) authorized.GET("/deployments/stats", deploymentCtrl.GetStats)
// Collectors(日志采集器 / 部署客户端)
authorized.GET("/collectors", collectorCtrl.List)
authorized.GET("/collectors/:id", collectorCtrl.Get)
authorized.POST("/collectors", collectorCtrl.Create)
authorized.PUT("/collectors/:id", collectorCtrl.Update)
authorized.DELETE("/collectors/:id", collectorCtrl.Delete)
authorized.POST("/collectors/:id/install", collectorCtrl.Install)
authorized.POST("/collectors/:id/uninstall", collectorCtrl.Uninstall)
// Static files - use executable directory as base // Static files - use executable directory as base
distPath := findDistPath() distPath := findDistPath()
+268
View File
@@ -0,0 +1,268 @@
package services
import (
"context"
"fmt"
"time"
"lokideploy/models"
"lokideploy/utils"
)
type CollectorService struct{}
func NewCollectorService() *CollectorService {
return &CollectorService{}
}
type CreateCollectorRequest struct {
Name string
Type string
LokiDeploymentID uint
HostID *uint
ClusterID *uint
TargetDeployment string
Namespace string
LogPath string
Labels string
}
// fillAssociations 填充关联的 Loki / Host / Cluster(用于配置生成与展示)
func (s *CollectorService) fillAssociations(c *models.Collector) {
db := models.GetDB()
db.RLock()
defer db.RUnlock()
for i := range db.Deployments {
if db.Deployments[i].ID == c.LokiDeploymentID {
d := db.Deployments[i]
c.LokiDeployment = &d
break
}
}
if c.HostID != nil {
for i := range db.Hosts {
if db.Hosts[i].ID == *c.HostID {
h := db.Hosts[i]
c.Host = &h
break
}
}
}
if c.ClusterID != nil {
for i := range db.Clusters {
if db.Clusters[i].ID == *c.ClusterID {
cl := db.Clusters[i]
c.Cluster = &cl
break
}
}
}
}
// generateConfigSnapshot 按类型生成配置快照
func (s *CollectorService) generateConfigSnapshot(c *models.Collector) string {
endpoint := ""
if c.LokiDeployment != nil {
endpoint = c.LokiDeployment.Endpoint
}
switch c.Type {
case "host-promtail":
return PromtailConfig(endpoint, c.LogPath)
case "k8s-daemonset":
return PromtailHelmValues(endpoint)
case "k8s-fluentbit-sidecar":
host, port := parseLokiURL(endpoint)
app := c.TargetDeployment
ns := c.Namespace
if ns == "" {
ns = "default"
}
return FluentbitConfig(host, port, c.LogPath, app, ns)
}
return ""
}
func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collector, error) {
switch req.Type {
case "host-promtail", "k8s-daemonset", "k8s-fluentbit-sidecar":
default:
return nil, fmt.Errorf("不支持的采集器类型: %s", req.Type)
}
if req.LokiDeploymentID == 0 {
return nil, fmt.Errorf("请选择关联的 Loki 部署")
}
if req.Type == "host-promtail" && (req.HostID == nil || *req.HostID == 0) {
return nil, fmt.Errorf("主机采集器需要选择目标主机")
}
if (req.Type == "k8s-daemonset" || req.Type == "k8s-fluentbit-sidecar") && (req.ClusterID == nil || *req.ClusterID == 0) {
return nil, fmt.Errorf("K8s 采集器需要选择目标集群")
}
if req.Type == "k8s-fluentbit-sidecar" && req.TargetDeployment == "" {
return nil, fmt.Errorf("Sidecar 采集器需要填写目标 Deployment 名称")
}
refType := "cluster"
if req.Type == "host-promtail" {
refType = "host"
}
collector := models.Collector{
ID: models.GetDB().NextID("collectors"),
Name: req.Name,
Type: req.Type,
LokiDeploymentID: req.LokiDeploymentID,
RefType: refType,
HostID: req.HostID,
ClusterID: req.ClusterID,
TargetDeployment: req.TargetDeployment,
Namespace: req.Namespace,
LogPath: req.LogPath,
Labels: req.Labels,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
s.fillAssociations(&collector)
if collector.LokiDeployment == nil {
return nil, fmt.Errorf("关联的 Loki 部署不存在")
}
collector.Config = s.generateConfigSnapshot(&collector)
db := models.GetDB()
db.Lock()
db.Collectors = append(db.Collectors, collector)
db.Unlock()
go db.Save()
go s.Install(collector.ID)
return &collector, nil
}
func (s *CollectorService) Get(id uint) (*models.Collector, error) {
db := models.GetDB()
db.RLock()
var found *models.Collector
for i := range db.Collectors {
if db.Collectors[i].ID == id {
c := db.Collectors[i]
found = &c
break
}
}
db.RUnlock()
if found == nil {
return nil, utils.ErrNotFound
}
s.fillAssociations(found)
return found, nil
}
func (s *CollectorService) List(page, pageSize int) ([]models.Collector, int64, error) {
db := models.GetDB()
db.RLock()
total := int64(len(db.Collectors))
start := (page - 1) * pageSize
if start < 0 || start >= len(db.Collectors) {
db.RUnlock()
return []models.Collector{}, total, nil
}
end := start + pageSize
if end > len(db.Collectors) {
end = len(db.Collectors)
}
result := make([]models.Collector, end-start)
copy(result, db.Collectors[start:end])
db.RUnlock()
for i := range result {
s.fillAssociations(&result[i])
}
return result, total, nil
}
func (s *CollectorService) Update(id uint, updates map[string]interface{}) error {
db := models.GetDB()
db.Lock()
defer db.Unlock()
for i := range db.Collectors {
if db.Collectors[i].ID == id {
if v, ok := updates["name"].(string); ok && v != "" {
db.Collectors[i].Name = v
}
if v, ok := updates["status"].(string); ok && v != "" {
db.Collectors[i].Status = v
}
if v, ok := updates["statusMessage"].(string); ok {
db.Collectors[i].StatusMessage = v
}
if v, ok := updates["logPath"].(string); ok {
db.Collectors[i].LogPath = v
}
if v, ok := updates["labels"].(string); ok {
db.Collectors[i].Labels = v
}
db.Collectors[i].UpdatedAt = time.Now()
go db.Save()
return nil
}
}
return utils.ErrNotFound
}
func (s *CollectorService) Delete(id uint) error {
collector, err := s.Get(id)
if err != nil {
return utils.ErrNotFound
}
db := models.GetDB()
db.Lock()
for i, c := range db.Collectors {
if c.ID == id {
db.Collectors = append(db.Collectors[:i], db.Collectors[i+1:]...)
break
}
}
db.Unlock()
go db.Save()
// 异步卸载(用拷贝的 collector,避免删除后取不到)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
NewCollectorDeployer(collector, collector.LokiDeployment, nil).Uninstall(ctx)
}()
return nil
}
// Install 异步部署采集器
func (s *CollectorService) Install(id uint) {
collector, err := s.Get(id)
if err != nil {
return
}
if collector.LokiDeployment == nil {
s.Update(id, map[string]interface{}{"status": "error", "statusMessage": "关联的 Loki 不存在"})
return
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
if err := NewCollectorDeployer(collector, collector.LokiDeployment, nil).Install(ctx); err != nil {
s.Update(id, map[string]interface{}{"status": "error", "statusMessage": err.Error()})
return
}
s.Update(id, map[string]interface{}{"status": "active", "statusMessage": "采集器已部署"})
}
// Uninstall 异步卸载采集器
func (s *CollectorService) Uninstall(id uint) {
collector, err := s.Get(id)
if err != nil {
return
}
if collector.LokiDeployment == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
NewCollectorDeployer(collector, collector.LokiDeployment, nil).Uninstall(ctx)
s.Update(id, map[string]interface{}{"status": "pending", "statusMessage": "采集器已卸载"})
}
+331
View File
@@ -0,0 +1,331 @@
package services
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"lokideploy/models"
)
// CollectorDeployer 采集器部署器,按 type 分发到三种安装/卸载流程
type CollectorDeployer struct {
collector *models.Collector
loki *models.LokiDeployment
logFunc func(level, message, detail string)
}
func NewCollectorDeployer(c *models.Collector, l *models.LokiDeployment, logFunc func(level, message, detail string)) *CollectorDeployer {
return &CollectorDeployer{collector: c, loki: l, logFunc: logFunc}
}
func (d *CollectorDeployer) log(level, message, detail string) {
if d.logFunc != nil {
d.logFunc(level, message, detail)
}
}
// kubeconfigEnv 构造 kubectl/helm 所需的 KUBECONFIG 环境变量,返回 cleanup 删除临时文件
func kubeconfigEnv(cluster *models.Cluster) (env []string, cleanup func(), err error) {
kubeconfig, err := resolveKubeconfig(cluster)
if err != nil {
return nil, nil, err
}
kubeFile, err := os.CreateTemp("", "kube-*.yaml")
if err != nil {
return nil, nil, err
}
kubeFile.WriteString(kubeconfig)
kubeFile.Close()
env = append(os.Environ(), fmt.Sprintf("KUBECONFIG=%s", kubeFile.Name()))
cleanup = func() { os.Remove(kubeFile.Name()) }
return env, cleanup, nil
}
// Install 按采集器类型分发安装
func (d *CollectorDeployer) Install(ctx context.Context) error {
endpoint := d.loki.Endpoint
if endpoint == "" {
return fmt.Errorf("关联的 Loki 尚未部署,缺少推送地址(endpoint),请先成功部署该 Loki")
}
d.log("info", fmt.Sprintf("开始部署采集器 %s(类型 %s)", d.collector.Name, d.collector.Type), "")
var err error
switch d.collector.Type {
case "host-promtail":
err = d.installHost(ctx, endpoint)
case "k8s-daemonset":
err = d.installDaemonset(ctx, endpoint)
case "k8s-fluentbit-sidecar":
err = d.installSidecar(ctx, endpoint)
default:
return fmt.Errorf("不支持的采集器类型: %s", d.collector.Type)
}
if err != nil {
d.log("error", fmt.Sprintf("采集器部署失败: %v", err), "")
return err
}
d.log("success", "采集器部署完成", "")
return nil
}
// Uninstall 按采集器类型分发卸载
func (d *CollectorDeployer) Uninstall(ctx context.Context) error {
switch d.collector.Type {
case "host-promtail":
return d.uninstallHost(ctx)
case "k8s-daemonset":
return d.uninstallDaemonset(ctx)
case "k8s-fluentbit-sidecar":
return d.uninstallSidecar(ctx)
}
return nil
}
// ===== host-promtail:SSH 安装 Promtail 二进制 + systemd =====
func (d *CollectorDeployer) installHost(ctx context.Context, endpoint string) error {
host := d.collector.Host
if host == nil {
return fmt.Errorf("主机采集器缺少目标主机")
}
password, err := DecryptCred(host.Password)
if err != nil {
return err
}
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
if err != nil {
return err
}
defer client.Close()
// 架构
archOut, _, _ := client.Execute(ctx, "uname -m")
arch := strings.TrimSpace(archOut)
switch arch {
case "x86_64":
arch = "amd64"
case "aarch64", "arm64":
arch = "arm64"
default:
return fmt.Errorf("不支持的主机架构: %s", arch)
}
// 下载 Promtail
d.log("info", fmt.Sprintf("下载 Promtail(%s)", arch), "")
url := fmt.Sprintf("https://github.com/grafana/loki/releases/download/v3.0.0/promtail-linux-%s.zip", arch)
dl := fmt.Sprintf("mkdir -p /opt/promtail /etc/promtail && cd /tmp && curl -fsSL -o p.zip '%s' && unzip -o p.zip -d /opt/promtail/ && chmod +x /opt/promtail/promtail-linux-%s", url, arch)
if _, _, err := client.Execute(ctx, dl); err != nil {
return fmt.Errorf("下载 Promtail 失败: %v", err)
}
client.Execute(ctx, "ln -sf /opt/promtail/promtail-linux-* /usr/local/bin/promtail 2>/dev/null || true")
// 配置
logPath := d.collector.LogPath
if logPath == "" {
logPath = "/var/log/*.log"
}
conf := PromtailConfig(endpoint, logPath)
client.Execute(ctx, fmt.Sprintf("cat > /etc/promtail/promtail.yaml << 'EOF'\n%s\nEOF", conf))
// systemd 服务
service := `[Unit]
Description=Promtail Log Collector
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/promtail -config.file=/etc/promtail/promtail.yaml
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
`
client.Execute(ctx, fmt.Sprintf("cat > /etc/systemd/system/promtail.service << 'EOF'\n%s\nEOF", service))
if _, _, err := client.Execute(ctx, "systemctl daemon-reload && systemctl enable promtail && systemctl restart promtail"); err != nil {
return fmt.Errorf("启动 Promtail 失败: %v", err)
}
return nil
}
func (d *CollectorDeployer) uninstallHost(ctx context.Context) error {
host := d.collector.Host
if host == nil {
return nil
}
password, _ := DecryptCred(host.Password)
client, err := NewSSHClient(host.HostIP, host.Port, host.User, password)
if err != nil {
return err
}
defer client.Close()
client.Execute(ctx, "systemctl stop promtail 2>/dev/null || true; systemctl disable promtail 2>/dev/null || true; rm -f /etc/systemd/system/promtail.service /usr/local/bin/promtail; systemctl daemon-reload")
return nil
}
// ===== k8s-daemonset:helm 安装 Promtail DaemonSet =====
func (d *CollectorDeployer) installDaemonset(ctx context.Context, endpoint string) error {
cluster := d.collector.Cluster
if cluster == nil {
return fmt.Errorf("DaemonSet 采集器缺少目标集群")
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
return err
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" {
ns = "logging"
}
values := PromtailHelmValues(endpoint)
valuesFile, err := os.CreateTemp("", "promtail-values-*.yaml")
if err != nil {
return err
}
defer os.Remove(valuesFile.Name())
valuesFile.WriteString(values)
valuesFile.Close()
d.log("info", fmt.Sprintf("helm install promtail(命名空间 %s)", ns), "")
// 先添加 grafana repo(幂等)
repo := exec.CommandContext(ctx, "helm", "repo", "add", "grafana", "https://grafana.github.io/helm-charts")
repo.Env = env
repo.CombinedOutput()
exec.CommandContext(ctx, "helm", "repo", "update").CombinedOutput()
cmd := exec.CommandContext(ctx, "helm", "upgrade", "--install", "promtail", "grafana/promtail",
"-f", valuesFile.Name(), "--namespace", ns, "--create-namespace", "--wait", "--timeout", "5m")
cmd.Env = env
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("helm 安装 Promtail 失败: %v\n%s", err, string(out))
}
return nil
}
func (d *CollectorDeployer) uninstallDaemonset(ctx context.Context) error {
cluster := d.collector.Cluster
if cluster == nil {
return nil
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
return err
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" {
ns = "logging"
}
cmd := exec.CommandContext(ctx, "helm", "uninstall", "promtail", "--namespace", ns)
cmd.Env = env
cmd.CombinedOutput()
return nil
}
// ===== k8s-fluentbit-sidecar:kubectl patch 注入 Fluent Bit sidecar =====
func (d *CollectorDeployer) installSidecar(ctx context.Context, endpoint string) error {
cluster := d.collector.Cluster
if cluster == nil {
return fmt.Errorf("Sidecar 采集器缺少目标集群")
}
target := d.collector.TargetDeployment
if target == "" {
return fmt.Errorf("Sidecar 采集器缺少目标 Deployment")
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
return err
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" {
ns = "default"
}
logPath := d.collector.LogPath
if logPath == "" {
logPath = "/var/log/app/*.log"
}
app := target
// 1. 检查目标 deployment 是否存在
d.log("info", fmt.Sprintf("检查目标 Deployment %s/%s", ns, target), "")
check := exec.CommandContext(ctx, "kubectl", "get", "deployment", target, "-n", ns)
check.Env = env
if out, err := check.CombinedOutput(); err != nil {
return fmt.Errorf("目标 Deployment 不存在: %s/%s\n%s", ns, target, string(out))
}
// 2. 幂等检查:是否已有 fluent-bit 容器
getNames := exec.CommandContext(ctx, "kubectl", "get", "deployment", target, "-n", ns, "-o", "jsonpath={.spec.template.spec.containers[*].name}")
getNames.Env = env
namesOut, _ := getNames.CombinedOutput()
if strings.Contains(string(namesOut), "fluent-bit") {
d.log("warn", "目标 Deployment 已含 fluent-bit 容器,跳过注入", "")
return nil
}
// 3. 部署 ConfigMap
lokiHost, lokiPort := parseLokiURL(endpoint)
conf := FluentbitConfig(lokiHost, lokiPort, logPath, app, ns)
cm := FluentbitConfigMapYAML(conf, ns)
cmFile, _ := os.CreateTemp("", "fluent-bit-cm-*.yaml")
defer os.Remove(cmFile.Name())
cmFile.WriteString(cm)
cmFile.Close()
d.log("info", "部署 Fluent Bit ConfigMap", "")
applyCm := exec.CommandContext(ctx, "kubectl", "apply", "-f", cmFile.Name())
applyCm.Env = env
if out, err := applyCm.CombinedOutput(); err != nil {
return fmt.Errorf("ConfigMap 部署失败: %v\n%s", err, string(out))
}
// 4. patch 注入 sidecar
d.log("info", fmt.Sprintf("为 %s 注入 Fluent Bit Sidecar", target), "")
patchFile, _ := os.CreateTemp("", "sidecar-patch-*.yaml")
defer os.Remove(patchFile.Name())
patchFile.WriteString(SidecarPatchYAML)
patchFile.Close()
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", target, "-n", ns, "--type=strategic", "--patch-file", patchFile.Name())
patch.Env = env
if out, err := patch.CombinedOutput(); err != nil {
return fmt.Errorf("Sidecar 注入失败: %v\n%s", err, string(out))
}
// 5. 等待 rollout
d.log("info", "等待 Pod 重建", "")
rollout := exec.CommandContext(ctx, "kubectl", "rollout", "status", "deployment/"+target, "-n", ns, "--timeout=5m")
rollout.Env = env
rollout.CombinedOutput()
return nil
}
func (d *CollectorDeployer) uninstallSidecar(ctx context.Context) error {
cluster := d.collector.Cluster
if cluster == nil || d.collector.TargetDeployment == "" {
return nil
}
env, cleanup, err := kubeconfigEnv(cluster)
if err != nil {
return err
}
defer cleanup()
ns := d.collector.Namespace
if ns == "" {
ns = "default"
}
patchFile, _ := os.CreateTemp("", "sidecar-rm-*.yaml")
defer os.Remove(patchFile.Name())
patchFile.WriteString(SidecarRemovePatchYAML)
patchFile.Close()
patch := exec.CommandContext(ctx, "kubectl", "patch", "deployment", d.collector.TargetDeployment, "-n", ns, "--type=strategic", "--patch-file", patchFile.Name())
patch.Env = env
patch.CombinedOutput()
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package services
import (
"fmt"
"strings"
)
// PromtailConfig 主机 Promtail 配置(推送至 lokiEndpoint,采集 logPath)
func PromtailConfig(lokiEndpoint, logPath string) string {
return fmt.Sprintf(`server:
http_listen_port: 9080
grpc_listen_port: 0
log_level: info
positions:
filename: /tmp/positions.yaml
clients:
- url: %s/loki/api/v1/push
scrape_configs:
- job_name: app
static_configs:
- targets: [localhost]
labels:
job: app
host: ${HOSTNAME}
__path__: %s
`, lokiEndpoint, logPath)
}
// PromtailHelmValues DaemonSet 模式 Promtail Helm values
func PromtailHelmValues(lokiEndpoint string) string {
return fmt.Sprintf(`config:
clients:
- url: %s/loki/api/v1/push
daemonset:
enabled: true
serviceAccount:
create: true
resources:
limits:
cpu: 200m
memory: 128Mi
requests:
cpu: 100m
memory: 64Mi
extraVolumes:
- name: varlog
hostPath:
path: /var/log
- name: varlibdockercontainers
hostPath:
path: /var/lib/docker/containers
extraVolumeMounts:
- name: varlog
mountPath: /var/log
readOnly: true
- name: varlibdockercontainers
mountPath: /var/lib/docker/containers
readOnly: true
`, lokiEndpoint)
}
// FluentbitConfig Sidecar 模式 Fluent Bit 配置(采集 logPath,推送至 lokiHost:lokiPort)
func FluentbitConfig(lokiHost, lokiPort, logPath, app, namespace string) string {
return fmt.Sprintf(`[SERVICE]
Flush 5
Log_Level info
HTTP_Server On
HTTP_Listen 0.0.0.0
HTTP_Port 2020
[INPUT]
Name tail
Path %s
Tag app.%s
Refresh_Interval 5
Mem_Buf_Limit 5MB
Skip_Long_Lines On
Read_from_Head True
DB /tmp/flb.db
[FILTER]
Name modify
Match app.*
Add namespace %s
Add app %s
[OUTPUT]
Name loki
Match *
Host %s
Port %s
Labels job=fluent-bit, app=%s, namespace=%s
`, logPath, app, namespace, app, lokiHost, lokiPort, app, namespace)
}
// FluentbitConfigMapYAML 包装 fluent-bit.conf 为 ConfigMap YAML(部署到 namespace)
func FluentbitConfigMapYAML(conf, namespace string) string {
return fmt.Sprintf(`apiVersion: v1
kind: ConfigMap
metadata:
name: fluent-bit-config
namespace: %s
data:
fluent-bit.conf: |
%s
`, namespace, indentBlock(conf, " "))
}
// SidecarPatchYAML strategic merge patch:为 Deployment 注入 Fluent Bit sidecar 容器
const SidecarPatchYAML = `spec:
template:
spec:
volumes:
- name: app-logs
emptyDir: {}
- name: fluent-bit-config
configMap:
name: fluent-bit-config
containers:
- name: fluent-bit
image: cr.fluentbit.io/fluent/fluent-bit:3.1.1
imagePullPolicy: IfNotPresent
volumeMounts:
- name: app-logs
mountPath: /var/log/app
readOnly: true
- name: fluent-bit-config
mountPath: /fluent-bit/etc/
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
`
// SidecarRemovePatchYAML strategic merge patch:从 Deployment 移除 Fluent Bit sidecar(卸载)
const SidecarRemovePatchYAML = `spec:
template:
spec:
containers:
- name: fluent-bit
$patch: delete
volumes:
- name: app-logs
$patch: delete
- name: fluent-bit-config
$patch: delete
`
// indentBlock 给每行加前缀(ConfigMap 嵌入配置需缩进)
func indentBlock(s, pad string) string {
lines := strings.Split(s, "\n")
for i, l := range lines {
if l != "" {
lines[i] = pad + l
}
}
return strings.Join(lines, "\n")
}
// parseLokiURL 从 http://host:port 提取 host 与 port(Fluent Bit OUTPUT 需要)
func parseLokiURL(endpoint string) (host, port string) {
u := strings.TrimSpace(endpoint)
u = strings.TrimPrefix(u, "http://")
u = strings.TrimPrefix(u, "https://")
if i := strings.Index(u, "/"); i >= 0 {
u = u[:i]
}
if i := strings.LastIndex(u, ":"); i >= 0 {
return u[:i], u[i+1:]
}
return u, "3100"
}
+1 -1
View File
@@ -1 +1 @@
46900 93417
+9
View File
@@ -6,6 +6,7 @@ import Clusters from '@/pages/Clusters'
import Hosts from '@/pages/Hosts' import Hosts from '@/pages/Hosts'
import Storage from '@/pages/Storage' import Storage from '@/pages/Storage'
import Deployments from '@/pages/Deployments' import Deployments from '@/pages/Deployments'
import Collectors from '@/pages/Collectors'
import Settings from '@/pages/Settings' import Settings from '@/pages/Settings'
function RequireAuth({ children }: { children: React.ReactNode }) { function RequireAuth({ children }: { children: React.ReactNode }) {
@@ -60,6 +61,14 @@ export default function App() {
</RequireAuth> </RequireAuth>
} }
/> />
<Route
path="/collectors"
element={
<RequireAuth>
<Collectors />
</RequireAuth>
}
/>
<Route <Route
path="/settings" path="/settings"
element={ element={
+3 -1
View File
@@ -5,6 +5,7 @@ import {
Monitor, Monitor,
HardDrive, HardDrive,
Rocket, Rocket,
RadioTower,
Settings, Settings,
LogOut, LogOut,
PanelLeftClose, PanelLeftClose,
@@ -21,7 +22,8 @@ const menuItems = [
{ path: '/clusters', icon: Server, label: '集群管理' }, { path: '/clusters', icon: Server, label: '集群管理' },
{ path: '/hosts', icon: Monitor, label: '主机管理' }, { path: '/hosts', icon: Monitor, label: '主机管理' },
{ path: '/storage', icon: HardDrive, label: '存储配置' }, { path: '/storage', icon: HardDrive, label: '存储配置' },
{ path: '/deployments', icon: Rocket, label: '部署管理' }, { path: '/deployments', icon: Rocket, label: '部署Loki' },
{ path: '/collectors', icon: RadioTower, label: '部署客户端' },
{ path: '/settings', icon: Settings, label: '系统设置' }, { path: '/settings', icon: Settings, label: '系统设置' },
] ]
+362
View File
@@ -0,0 +1,362 @@
import { useState, useEffect } from 'react'
import {
RadioTower,
Plus,
RefreshCw,
Trash2,
Upload,
Download,
Server,
Monitor,
Cloud,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Card, CardContent } from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { collectorApi, deploymentApi, clusterApi, hostApi } from '@/srv/api'
import { statusLabel } from '@/lib/utils'
import type { Collector, LokiDeployment, Cluster, Host } from '@/types'
const typeMeta: Record<string, { label: string; icon: typeof Server; color: string }> = {
'host-promtail': { label: '主机 Promtail', icon: Monitor, color: 'bg-purple-100 text-purple-700' },
'k8s-daemonset': { label: 'K8s DaemonSet', icon: Cloud, color: 'bg-blue-100 text-blue-700' },
'k8s-fluentbit-sidecar': { label: 'K8s Sidecar', icon: Server, color: 'bg-cyan-100 text-cyan-700' },
}
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'bg-emerald-100 text-emerald-700'
case 'error':
return 'bg-red-100 text-red-700'
case 'pending':
return 'bg-amber-100 text-amber-700'
default:
return 'bg-slate-100 text-slate-600'
}
}
export default function Collectors() {
const [collectors, setCollectors] = useState<Collector[]>([])
const [deployments, setDeployments] = useState<LokiDeployment[]>([])
const [clusters, setClusters] = useState<Cluster[]>([])
const [hosts, setHosts] = useState<Host[]>([])
const [loading, setLoading] = useState(true)
const [open, setOpen] = useState(false)
const [busy, setBusy] = useState<number | null>(null)
// Form
const [name, setName] = useState('')
const [type, setType] = useState<'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar'>('host-promtail')
const [lokiID, setLokiID] = useState('')
const [hostID, setHostID] = useState('')
const [clusterID, setClusterID] = useState('')
const [targetDeployment, setTargetDeployment] = useState('')
const [namespace, setNamespace] = useState('')
const [logPath, setLogPath] = useState('')
const [labels, setLabels] = useState('')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
loadAll()
}, [])
const loadAll = async () => {
try {
const [cRes, dRes, clRes, hRes] = await Promise.all([
collectorApi.list(1, 100),
deploymentApi.list(1, 100),
clusterApi.list(1, 100),
hostApi.list(1, 100),
])
setCollectors(cRes.list)
setDeployments(dRes.list)
setClusters(clRes.list)
setHosts(hRes.list)
} catch (err) {
console.error('Failed to load collectors:', err)
} finally {
setLoading(false)
}
}
const resetForm = () => {
setName('')
setType('host-promtail')
setLokiID('')
setHostID('')
setClusterID('')
setTargetDeployment('')
setNamespace('')
setLogPath('')
setLabels('')
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!lokiID) {
alert('请选择关联的 Loki 部署')
return
}
setSubmitting(true)
try {
await collectorApi.create({
name,
type,
lokiDeploymentID: Number(lokiID),
hostID: type === 'host-promtail' && hostID ? Number(hostID) : undefined,
clusterID: type !== 'host-promtail' && clusterID ? Number(clusterID) : undefined,
targetDeployment: type === 'k8s-fluentbit-sidecar' ? targetDeployment : undefined,
namespace: type !== 'host-promtail' ? namespace : undefined,
logPath,
labels,
})
setOpen(false)
resetForm()
loadAll()
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '创建采集器失败'
alert(message)
} finally {
setSubmitting(false)
}
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除此采集器?(将自动卸载)')) return
try {
await collectorApi.remove(id)
loadAll()
} catch (err) {
console.error('Failed to delete collector:', err)
}
}
const handleAction = async (id: number, action: 'install' | 'uninstall') => {
setBusy(id)
try {
await collectorApi[action](id)
setTimeout(loadAll, 3000)
} catch (err) {
console.error('Collector action failed:', err)
} finally {
setTimeout(() => setBusy(null), 3000)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">部署客户端</h2>
<p className="text-sm text-slate-500">部署日志采集器,将日志推送到关联的 Loki</p>
</div>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className="bg-emerald-600 hover:bg-emerald-700">
<Plus className="w-4 h-4 mr-1" />
新建采集器
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>新建日志采集器</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="c-name">名称 *</Label>
<Input id="c-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="app-logs-collector" required />
</div>
<div className="space-y-2">
<Label>采集器类型</Label>
<Select value={type} onValueChange={(v) => setType(v as 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar')}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="host-promtail">主机 Promtail(SSH)</SelectItem>
<SelectItem value="k8s-daemonset">K8s Promtail DaemonSet</SelectItem>
<SelectItem value="k8s-fluentbit-sidecar">K8s Fluent Bit Sidecar</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label>关联 Loki 部署 *</Label>
<Select value={lokiID} onValueChange={setLokiID}>
<SelectTrigger><SelectValue placeholder="选择已部署的 Loki(推送目标)" /></SelectTrigger>
<SelectContent>
{deployments.map((d) => (
<SelectItem key={d.id} value={String(d.id)}>{d.name}({statusLabel(d.status)})</SelectItem>
))}
</SelectContent>
</Select>
</div>
{type === 'host-promtail' && (
<div className="space-y-2">
<Label>目标主机 *</Label>
<Select value={hostID} onValueChange={setHostID}>
<SelectTrigger><SelectValue placeholder="选择主机" /></SelectTrigger>
<SelectContent>
{hosts.map((h) => (
<SelectItem key={h.id} value={String(h.id)}>{h.name}({h.hostIP})</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{type !== 'host-promtail' && (
<>
<div className="space-y-2">
<Label>目标集群 *</Label>
<Select value={clusterID} onValueChange={setClusterID}>
<SelectTrigger><SelectValue placeholder="选择集群" /></SelectTrigger>
<SelectContent>
{clusters.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.name}({statusLabel(c.status)})</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="c-ns">命名空间</Label>
<Input id="c-ns" value={namespace} onChange={(e) => setNamespace(e.target.value)} placeholder={type === 'k8s-daemonset' ? 'logging' : 'default'} />
</div>
{type === 'k8s-fluentbit-sidecar' && (
<div className="space-y-2">
<Label htmlFor="c-deploy">目标 Deployment *</Label>
<Input id="c-deploy" value={targetDeployment} onChange={(e) => setTargetDeployment(e.target.value)} placeholder="my-app" />
</div>
)}
</div>
</>
)}
<div className="space-y-2">
<Label htmlFor="c-log">采集日志路径</Label>
<Input id="c-log" value={logPath} onChange={(e) => setLogPath(e.target.value)} placeholder={type === 'host-promtail' ? '/var/log/*.log' : '/var/log/app/*.log'} />
</div>
<Button type="submit" className="w-full bg-emerald-600 hover:bg-emerald-700" disabled={submitting}>
{submitting ? '创建中...' : '创建并部署'}
</Button>
</form>
</DialogContent>
</Dialog>
</div>
{/* List */}
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw className="w-6 h-6 text-emerald-500 animate-spin" />
</div>
) : collectors.length === 0 ? (
<Card className="border-dashed border-slate-300">
<CardContent className="p-12 text-center">
<RadioTower className="w-12 h-12 text-slate-300 mx-auto mb-4" />
<p className="text-slate-500 font-medium">暂无采集器</p>
<p className="text-sm text-slate-400 mt-1">新建一个日志采集器,关联已部署的 Loki</p>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{collectors.map((c) => {
const meta = typeMeta[c.type] || { label: c.type, icon: RadioTower, color: 'bg-slate-100 text-slate-600' }
const Icon = meta.icon
return (
<Card key={c.id} className="border-slate-200 dark:border-slate-800 hover:shadow-md transition-shadow">
<CardContent className="p-5">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-2">
<Icon className="w-5 h-5 text-emerald-600" />
<div>
<h3 className="font-semibold text-slate-800 dark:text-white">{c.name}</h3>
<p className="text-xs text-slate-500">关联 Loki:{c.lokiDeployment?.name || '未知'}</p>
</div>
</div>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(c.status)}`}>
{statusLabel(c.status)}
</span>
</div>
<div className="space-y-1.5 text-xs text-slate-500 mb-3">
<div className="flex justify-between">
<span>类型</span>
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${meta.color}`}>{meta.label}</span>
</div>
{c.host && (
<div className="flex justify-between">
<span>主机</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{c.host.name}({c.host.hostIP})</span>
</div>
)}
{c.cluster && (
<div className="flex justify-between">
<span>集群</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{c.cluster.name}</span>
</div>
)}
{c.targetDeployment && (
<div className="flex justify-between">
<span>目标 Deployment</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{c.targetDeployment}</span>
</div>
)}
{c.namespace && (
<div className="flex justify-between">
<span>命名空间</span>
<span className="font-medium text-slate-700 dark:text-slate-300">{c.namespace}</span>
</div>
)}
{c.logPath && (
<div className="flex justify-between">
<span>日志路径</span>
<span className="font-medium text-slate-700 dark:text-slate-300 truncate max-w-[200px]">{c.logPath}</span>
</div>
)}
{c.statusMessage && (
<div className="p-2 bg-slate-50 dark:bg-slate-800 rounded text-xs mt-1">{c.statusMessage}</div>
)}
</div>
<div className="flex gap-2 pt-3 border-t border-slate-100 dark:border-slate-800">
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => handleAction(c.id, 'install')} disabled={busy === c.id}>
<Upload className="w-3 h-3 mr-1" />
{busy === c.id ? '处理中...' : '部署'}
</Button>
<Button variant="outline" size="sm" className="flex-1 text-xs" onClick={() => handleAction(c.id, 'uninstall')} disabled={busy === c.id}>
<Download className="w-3 h-3 mr-1" />
卸载
</Button>
<Button variant="outline" size="sm" className="text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => handleDelete(c.id)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
</div>
)
}
+14 -1
View File
@@ -12,6 +12,7 @@ import {
Clock, Clock,
PauseCircle, PauseCircle,
Activity, Activity,
ExternalLink,
} from 'lucide-react' } from 'lucide-react'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -240,7 +241,7 @@ export default function Deployments() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-semibold text-slate-800 dark:text-white">部署管理</h2> <h2 className="text-lg font-semibold text-slate-800 dark:text-white">部署Loki</h2>
<p className="text-sm text-slate-500">部署与管理 Grafana Loki 日志聚合服务</p> <p className="text-sm text-slate-500">部署与管理 Grafana Loki 日志聚合服务</p>
</div> </div>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -506,6 +507,18 @@ export default function Deployments() {
<Terminal className="w-3 h-3 mr-1" /> <Terminal className="w-3 h-3 mr-1" />
日志 日志
</Button> </Button>
{d.endpoint && (
<Button
variant="outline"
size="sm"
className="text-xs text-emerald-600 hover:text-emerald-700"
onClick={() => window.open(d.endpoint, '_blank')}
title="浏览器访问"
>
<ExternalLink className="w-3 h-3 mr-1" />
访问
</Button>
)}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
+20
View File
@@ -0,0 +1,20 @@
import { request } from './client';
import type { PageData, Collector, CreateCollectorRequest } from '@/types';
export const collectorApi = {
list: (page = 1, pageSize = 100) =>
request<PageData<Collector>>(`/collectors?page=${page}&pageSize=${pageSize}`),
get: (id: number) => request<Collector>(`/collectors/${id}`),
create: (data: CreateCollectorRequest) =>
request<Collector>('/collectors', { method: 'POST', body: JSON.stringify(data) }),
remove: (id: number) => request<{ message: string }>(`/collectors/${id}`, { method: 'DELETE' }),
install: (id: number) =>
request<{ message: string }>(`/collectors/${id}/install`, { method: 'POST' }),
uninstall: (id: number) =>
request<{ message: string }>(`/collectors/${id}/uninstall`, { method: 'POST' }),
};
+1
View File
@@ -4,3 +4,4 @@ export { clusterApi } from './cluster';
export { hostApi } from './host'; export { hostApi } from './host';
export { storageApi } from './storage'; export { storageApi } from './storage';
export { deploymentApi } from './deployment'; export { deploymentApi } from './deployment';
export { collectorApi } from './collector';
+34
View File
@@ -166,6 +166,40 @@ export interface PageData<T> {
pageSize: number; pageSize: number;
} }
// Collector(日志采集器 / 部署客户端)
export interface Collector {
id: number;
name: string;
type: 'host-promtail' | 'k8s-daemonset' | 'k8s-fluentbit-sidecar';
lokiDeploymentID: number;
lokiDeployment?: { id: number; name: string; endpoint: string; status: string };
refType: string;
hostID?: number;
host?: Host;
clusterID?: number;
cluster?: Cluster;
targetDeployment: string;
namespace: string;
logPath: string;
labels: string;
status: string;
statusMessage: string;
createdAt: string;
updatedAt: string;
}
export interface CreateCollectorRequest {
name: string;
type: string;
lokiDeploymentID: number;
hostID?: number;
clusterID?: number;
targetDeployment?: string;
namespace?: string;
logPath?: string;
labels?: string;
}
// Dashboard Stats // Dashboard Stats
export interface DashboardStats { export interface DashboardStats {
totalDeployments: number; totalDeployments: number;