feat: K8s 部署清单、Token 接入加固与日志/设置页优化

- kubernetes/: 30Gi PVC、单副本 Recreate、Secret/Service/NodePort/Ingress 清单与部署文档
- 数据安全: kubeconfig/token 统一加密存储,启动迁移历史明文,DB 0600
- Token 接入: 命名空间级最小权限提示、长期 Token、自签证书 TLS 兼容
- 部署: 命名空间级 Token 下跳过 Namespace patch/枚举,节点信息容错
- 日志页: namespace → app → pod 选择顺序与作用域收窄
- 设置页: 拆分为通用/告警/LDAP/镜像源四个 tab
- 弹框: 统一 max-h 滚动与长命令换行,修复内容溢出
This commit is contained in:
logan committed 2026-10-25 23:44:07 +08:00
1 parent 2d73661dd1
commit 9f7c522157
50 files changed
+1346 -149

No files matched your search

+13
View File
@@ -45,3 +45,16 @@ secrets/
# Build artifacts
data/
logs/
opsxloki.json
*.db
*.db-journal
*.sqlite
*.sqlite3
*.bak
*.tmp
# Local deployment manifests / dev compose (not part of image)
kubernetes/
docker-compose*.yml
.kube/
.ssh/
+1 -1
View File
@@ -22,7 +22,7 @@ func (ctrl *ImageRegistryController) Save(c *gin.Context) {
if !utils.BindAndValidate(c, &req) {
return
}
if err := services.SaveImageRegistry(req.Mode, req.CustomHost); err != nil {
if err := services.SaveImageRegistry(req.Mode, req.CustomHost, req.Images); err != nil {
utils.Error(c, 400, err.Error())
return
}
+3
View File
@@ -63,6 +63,9 @@ func NewLogsController() *LogsController {
// ==================== 数据源 ====================
func (ctrl *LogsController) ListSources(c *gin.Context) {
if c.GetString("role") == "admin" {
services.EnsureLogSourcesForRunningDeployments()
}
list := services.ListVisibleLogSources(c.GetUint("userId"), c.GetString("role") == "admin")
utils.Success(c, utils.PageData{List: list, Total: int64(len(list)), Page: 1, PageSize: 100})
}
+5 -2
View File
@@ -1,6 +1,9 @@
package dto
import "opsxloki/services"
type ImageRegistryRequest struct {
Mode string `json:"mode" binding:"required,oneof=overseas cn custom"`
CustomHost string `json:"customHost"`
Mode string `json:"mode" binding:"required,oneof=overseas cn custom"`
CustomHost string `json:"customHost"`
Images map[string]services.ComponentImage `json:"images"`
}
+2 -2
View File
@@ -3,7 +3,7 @@ package dto
// 存储域
type CreateStorageRequest struct {
Name string `json:"name" binding:"required"`
StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs"`
StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs cos obs"`
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket" binding:"required"`
Region string `json:"region"`
@@ -16,7 +16,7 @@ type CreateStorageRequest struct {
// StorageTestRequest 测试连接(不落库;createIfMissing 时桶不存在则自动创建)
type StorageTestRequest struct {
StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs"`
StorageType string `json:"storageType" binding:"required,oneof=s3 oss minio rustfs cos obs"`
Endpoint string `json:"endpoint" binding:"required"`
Bucket string `json:"bucket" binding:"required"`
Region string `json:"region"`
+3
View File
@@ -27,6 +27,9 @@ func main() {
log.Println("Seeding default data...")
seed.Seed()
// 安全迁移:历史明文 kubeconfig → 加密存储,并清理明文文件
services.MigrateClusterKubeconfigs()
// 孤儿部署状态恢复:进程重启会杀掉进行中的部署 goroutine,
// 残留的 "deploying" 状态将永远卡住(UI 无停止入口),启动时统一标记为失败。
repository.GetDB().RecoverOrphanDeployments()
+2 -2
View File
@@ -53,7 +53,7 @@ func GetDB() *JsonDB {
func InitDB() error {
cfg := config.GetConfig()
_ = os.MkdirAll(cfg.DataDir, 0755)
_ = os.MkdirAll(cfg.DataDir, 0700)
d := &JsonDB{
dataFile: cfg.DatabasePath,
@@ -258,7 +258,7 @@ func (d *JsonDB) doSave() error {
dir := filepath.Dir(d.dataFile)
tmpFile := filepath.Join(dir, fmt.Sprintf(".opsxloki-%d.tmp", time.Now().UnixNano()))
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
return err
}
+56 -17
View File
@@ -6,6 +6,7 @@ import (
"opsxloki/pkg/crypto"
"os"
"path/filepath"
"strings"
"time"
"opsxloki/config"
@@ -32,6 +33,39 @@ func SaveKubeConfig(name, content string) (string, error) {
return path, nil
}
// MigrateClusterKubeconfigs 启动时把历史明文 kubeconfig 加密回 DB,并删除明文文件。
// 幂等:已加密的直接跳过。
func MigrateClusterKubeconfigs() {
db := repository.GetDB()
if db == nil {
return
}
kubeDir := filepath.Clean(config.GetConfig().KubeDir)
db.Lock()
changed := false
for i := range db.Clusters {
c := &db.Clusters[i]
if c.KubeConfig == "" || c.IsKubeconfigEncrypted() {
continue
}
if err := c.EncryptAndSetKubeconfig(c.KubeConfig); err != nil {
continue
}
if c.KubeConfigPath != "" {
p := filepath.Clean(c.KubeConfigPath)
if strings.HasPrefix(p, kubeDir+string(os.PathSeparator)) {
_ = os.Remove(p)
}
c.KubeConfigPath = ""
}
changed = true
}
db.Unlock()
if changed {
_ = db.RequestSaveSync()
}
}
// Create 创建集群。token 接入时自动构造等价 kubeconfig,统一存储,使部署模块同样可用。
func (s *ClusterService) Create(name, description, configType, apiServer, kubeconfig, token, contextName string) (*models.Cluster, error) {
if configType == "" {
@@ -40,33 +74,32 @@ func (s *ClusterService) Create(name, description, configType, apiServer, kubeco
finalKubeconfig := kubeconfig
if configType == "token" {
// 安全默认:不嵌入 CA、不跳过 TLS 验证,依赖系统 CA 池。
// 自签证书的集群请改用 kubeconfig 模式(其内含 CA 证书)。
finalKubeconfig = BuildKubeconfigFromToken(apiServer, token, "", false)
// Token 模式通常用于内网/自签证书集群:默认跳过 TLS 校验。
// 如需严格校验,请使用 kubeconfig 模式(其中包含 CA 证书)。
finalKubeconfig = BuildKubeconfigFromToken(apiServer, token, "", true) // Token 模式默认跳过 TLS 校验,兼容内网自签证书
}
// 展示用 APIServer:token 模式取入参,kubeconfig 模式从内容提取
if apiServer == "" {
apiServer = ExtractAPIServer(finalKubeconfig)
}
path, err := SaveKubeConfig(name, finalKubeconfig)
if err != nil {
return nil, fmt.Errorf("kubeconfig 保存失败: %w", err)
}
// 安全加固:kubeconfig 只加密存 DB,不再落明文文件
cluster := models.Cluster{
ID: repository.GetDB().NextID("clusters"),
Name: name,
Description: description,
ConfigType: configType,
APIServer: apiServer,
KubeConfig: finalKubeconfig,
KubeConfigPath: path,
KubeConfig: "",
KubeConfigPath: "",
Context: contextName,
Status: "pending",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := cluster.EncryptAndSetKubeconfig(finalKubeconfig); err != nil {
return nil, fmt.Errorf("kubeconfig 加密失败: %w", err)
}
db := repository.GetDB()
db.Lock()
@@ -144,18 +177,24 @@ func (s *ClusterService) Update(id uint, updates map[string]interface{}) error {
if v, ok := updates["apiServer"].(string); ok {
db.Clusters[i].APIServer = v
}
// kubeconfig 编辑约定:非空才更新(留空=保留旧密文)
// 凭据:kubeconfig 或 token 二选一,非空才更新,统一加密存 DB
if v, ok := updates["kubeconfig"].(string); ok && v != "" {
if enc, err := crypto.EncryptField(v); err == nil {
db.Clusters[i].KubeConfig = enc
db.Clusters[i].KubeConfigPath = ""
}
}
// 凭据字段:空值不覆盖,避免编辑其它项时清空
if v, ok := updates["kubeconfig"].(string); ok && v != "" {
db.Clusters[i].KubeConfig = v
if db.Clusters[i].KubeConfigPath != "" {
_ = os.WriteFile(db.Clusters[i].KubeConfigPath, []byte(v), 0600)
} else if tok, ok := updates["token"].(string); ok && tok != "" {
apiServer := db.Clusters[i].APIServer
if v, ok := updates["apiServer"].(string); ok && v != "" {
apiServer = v
}
if apiServer == "" {
return fmt.Errorf("Token 接入需要 API Server")
}
kc := BuildKubeconfigFromToken(apiServer, tok, "", true) // Token 模式默认跳过 TLS 校验
if enc, err := crypto.EncryptField(kc); err == nil {
db.Clusters[i].KubeConfig = enc
db.Clusters[i].KubeConfigPath = ""
}
}
db.Clusters[i].UpdatedAt = time.Now()
+9 -2
View File
@@ -3,6 +3,8 @@ package services
import (
"context"
"fmt"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/tools/clientcmd"
"opsxloki/models"
@@ -41,10 +43,15 @@ func GetClusterInfo(ctx context.Context, cluster *models.Cluster) (*ClusterInfo,
info.Version = version.GitVersion
}
nodes, err := client.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
if err != nil {
if err != nil && !apierrors.IsForbidden(err) {
return nil, fmt.Errorf("获取节点列表失败: %w", err)
}
for _, item := range nodes.Items {
// 命名空间级 Token 通常没有 nodes 权限:跳过节点信息,不影响集群可用性
var nodeItems []corev1.Node
if nodes != nil {
nodeItems = nodes.Items
}
for _, item := range nodeItems {
status := "Unknown"
for _, cond := range item.Status.Conditions {
if cond.Type == "Ready" {
+5
View File
@@ -7,6 +7,7 @@ import (
"sort"
"time"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"opsxloki/models"
)
@@ -26,6 +27,10 @@ func ListClusterNamespaces(ctx context.Context, cluster *models.Cluster) ([]stri
}
items, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
if err != nil {
if apierrors.IsForbidden(err) {
// 命名空间级 Token 无权枚举命名空间:返回空列表,由用户手填
return []string{}, nil
}
return nil, fmt.Errorf("读取集群命名空间失败: %w", err)
}
result := make([]string, 0, len(items.Items))
+12 -10
View File
@@ -160,8 +160,8 @@ daemonset:
resources:
limits:
cpu: 200m
memory: 256Mi
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
@@ -194,7 +194,7 @@ extraVolumeMounts:
// 取代 helm 安装 —— 后端无需安装 helm 二进制。
// relabel 体系与主流 helm chart 对齐:namespace/pod/container/node_name + __path__ 按 pod uid 精确匹配。
func PromtailDaemonsetYAML(ns, lokiURL, clusterName, labelsJSON string) string {
promtailImage := ResolveImage("docker.io/grafana/promtail:3.4.0")
promtailImage := ResolveImageFor("promtail", "grafana/promtail", "3.4.0")
extLabels := promtailExternalLabels(clusterName, labelsJSON)
return fmt.Sprintf(`apiVersion: v1
kind: ServiceAccount
@@ -310,13 +310,15 @@ spec:
port: http-metrics
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 5
resources:
requests:
cpu: 50m
memory: 64Mi
cpu: 100m
memory: 128Mi
limits:
cpu: 200m
memory: 256Mi
cpu: 500m
memory: 512Mi
volumeMounts:
- name: config
mountPath: /etc/promtail
@@ -447,8 +449,8 @@ func SidecarPatchYAML(configMapName string, mount sidecarLogMount) string {
mountPath: /tmp
resources:
limits:
cpu: 200m
memory: 256Mi
cpu: 500m
memory: 512Mi
requests:
cpu: 100m
memory: 128Mi
@@ -470,7 +472,7 @@ func SidecarPatchYAML(configMapName string, mount sidecarLogMount) string {
port: fluent-bit-http
initialDelaySeconds: 5
periodSeconds: 5
`, volume, configMapName, appMount, ResolveImage("docker.io/fluent/fluent-bit:5.1.5"), mount.volumeName, mount.mountPath, subPath)
`, volume, configMapName, appMount, ResolveImageFor("fluentbit", "fluent/fluent-bit", "5.1.5"), mount.volumeName, mount.mountPath, subPath)
}
// indentBlock 给每行加前缀(ConfigMap 嵌入配置需缩进)
+87 -28
View File
@@ -3,6 +3,7 @@ package services
// 镜像仓库源 — 全局设置(参照 kuboard 的国内/海外镜像选择):
// overseas = docker.io 官方源;cn = daocloud 国内镜像;custom = 用户自填 registry 前缀。
// 影响平台拉取的全部镜像:Loki / Grafana / Promtail / Fluent Bit。
// 每个组件可单独配置仓库与 tag(Loki/Grafana 的 tag 留空则跟随部署版本)。
import (
"encoding/json"
"fmt"
@@ -20,15 +21,23 @@ const (
daocloudPrefix = "docker.m.daocloud.io"
)
// ComponentImage 单个组件的镜像配置
type ComponentImage struct {
Repository string `json:"repository"` // 如 grafana/loki
Tag string `json:"tag"` // 留空表示跟随部署版本 / 使用默认
}
// ImageRegistryConfig 镜像源配置
type ImageRegistryConfig struct {
Mode string `json:"mode"` // overseas | cn | custom
CustomHost string `json:"customHost"` // custom 模式下的仓库前缀,如 swr.cn-east-2.myhuaweicloud.com/xxx
Mode string `json:"mode"` // overseas | cn | custom
CustomHost string `json:"customHost"` // custom 模式下的仓库前缀
Images map[string]ComponentImage `json:"images"` // loki/grafana/promtail/fluentbit
}
type ImageRegistryPublic struct {
Mode string `json:"mode"`
CustomHost string `json:"customHost"`
Mode string `json:"mode"`
CustomHost string `json:"customHost"`
Images map[string]ComponentImage `json:"images"`
// 示例预览(前端展示生效镜像)
SampleLoki string `json:"sampleLoki"`
SampleGrafana string `json:"sampleGrafana"`
@@ -36,31 +45,55 @@ type ImageRegistryPublic struct {
SampleFluentBit string `json:"sampleFluentBit"`
}
var defaultComponentImages = map[string]ComponentImage{
"loki": {Repository: "grafana/loki", Tag: ""}, // 跟随部署版本
"grafana": {Repository: "grafana/grafana", Tag: ""}, // 跟随部署版本
"promtail": {Repository: "grafana/promtail", Tag: "3.4.0"},
"fluentbit": {Repository: "fluent/fluent-bit", Tag: "5.1.5"},
}
func (c *ImageRegistryConfig) fillDefaults() {
if c.Images == nil {
c.Images = map[string]ComponentImage{}
}
for name, def := range defaultComponentImages {
img, ok := c.Images[name]
if !ok || (img.Repository == "" && img.Tag == "") {
c.Images[name] = def
continue
}
if img.Repository == "" {
img.Repository = def.Repository
}
c.Images[name] = img
}
}
func GetImageRegistry() *ImageRegistryConfig {
db := repository.GetDB()
if db == nil {
return &ImageRegistryConfig{Mode: RegistryOverseas}
}
db.RLock()
var raw string
for i := range db.Settings {
if db.Settings[i].Key == imageRegistrySettingKey {
raw = db.Settings[i].Value
break
}
}
db.RUnlock()
cfg := &ImageRegistryConfig{Mode: RegistryOverseas}
if raw != "" {
_ = json.Unmarshal([]byte(raw), cfg)
if db != nil {
db.RLock()
var raw string
for i := range db.Settings {
if db.Settings[i].Key == imageRegistrySettingKey {
raw = db.Settings[i].Value
break
}
}
db.RUnlock()
if raw != "" {
_ = json.Unmarshal([]byte(raw), cfg)
}
}
if cfg.Mode == "" {
cfg.Mode = RegistryOverseas
}
cfg.fillDefaults()
return cfg
}
func SaveImageRegistry(mode, customHost string) error {
func SaveImageRegistry(mode, customHost string, images map[string]ComponentImage) error {
if mode != RegistryOverseas && mode != RegistryCN && mode != RegistryCustom {
return fmt.Errorf("无效的镜像源模式")
}
@@ -70,7 +103,15 @@ func SaveImageRegistry(mode, customHost string) error {
return fmt.Errorf("自定义镜像仓库地址无效(需含域名,如 swr.cn-east-2.myhuaweicloud.com/your-ns)")
}
}
data, _ := json.Marshal(&ImageRegistryConfig{Mode: mode, CustomHost: customHost})
cfg := &ImageRegistryConfig{Mode: mode, CustomHost: customHost, Images: images}
cfg.fillDefaults()
// 清理空白输入
clean := map[string]ComponentImage{}
for name, img := range cfg.Images {
clean[name] = ComponentImage{Repository: strings.TrimSpace(img.Repository), Tag: strings.TrimSpace(img.Tag)}
}
cfg.Images = clean
data, _ := json.Marshal(cfg)
db := repository.GetDB()
newID := db.NextID("settings")
db.Lock()
@@ -97,7 +138,6 @@ func ResolveImage(official string) string {
return daocloudPrefix + "/" + strings.TrimPrefix(official, "docker.io/")
}
if !strings.Contains(strings.SplitN(official, "/", 2)[0], ".") {
// 无仓库前缀的官方镜像(grafana/loki:tag)
return daocloudPrefix + "/" + official
}
return official
@@ -110,22 +150,41 @@ func ResolveImage(official string) string {
}
return official
default:
if strings.HasPrefix(official, "docker.io/") {
return official // 官方模式保留全限定名
}
return official
}
}
// ResolveImageFor 按组件配置解析镜像;组件 tag 为空时使用 fallbackTag(通常是部署版本)。
func ResolveImageFor(component, fallbackRepo, fallbackTag string) string {
cfg := GetImageRegistry()
img, ok := cfg.Images[component]
repo := fallbackRepo
tag := fallbackTag
if ok {
if strings.TrimSpace(img.Repository) != "" {
repo = strings.TrimSpace(img.Repository)
}
if strings.TrimSpace(img.Tag) != "" {
tag = strings.TrimSpace(img.Tag)
}
}
official := repo
if !strings.Contains(repo, ":") && tag != "" {
official = repo + ":" + tag
}
return ResolveImage(official)
}
// ImageRegistryPublicConfig 对外输出(含示例预览)
func ImageRegistryPublicConfig() *ImageRegistryPublic {
cfg := GetImageRegistry()
return &ImageRegistryPublic{
Mode: cfg.Mode,
CustomHost: cfg.CustomHost,
SampleLoki: ResolveImage("docker.io/grafana/loki:3.4.0"),
SampleGrafana: ResolveImage("docker.io/grafana/grafana:11.3.0"),
SamplePromtail: ResolveImage("docker.io/grafana/promtail:3.4.0"),
SampleFluentBit: ResolveImage("docker.io/fluent/fluent-bit:5.1.5"),
Images: cfg.Images,
SampleLoki: ResolveImageFor("loki", "grafana/loki", "3.4.0"),
SampleGrafana: ResolveImageFor("grafana", "grafana/grafana", "11.3.0"),
SamplePromtail: ResolveImageFor("promtail", "grafana/promtail", "3.4.0"),
SampleFluentBit: ResolveImageFor("fluentbit", "fluent/fluent-bit", "5.1.5"),
}
}
+7
View File
@@ -16,6 +16,13 @@ import (
// resolveKubeconfig 取集群最终的 kubeconfig 文本(优先内存字段,其次磁盘文件)
func resolveKubeconfig(cluster *models.Cluster) (string, error) {
if cluster.KubeConfig != "" {
if cluster.IsKubeconfigEncrypted() {
plain := cluster.DecryptKubeconfig()
if plain == "" {
return "", fmt.Errorf("kubeconfig 解密失败(ENCRYPTION_KEY 是否变更?)")
}
return plain, nil
}
return cluster.KubeConfig, nil
}
if cluster.KubeConfigPath != "" {
+8
View File
@@ -32,6 +32,10 @@ func ensureNamespace(ctx context.Context, cluster *models.Cluster, namespace str
if apierrors.IsNotFound(err) {
_, err = client.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace}}, metav1.CreateOptions{})
}
if apierrors.IsForbidden(err) {
// 命名空间级 Token 无权读取 Namespace 对象:命名空间由管理员预建,直接继续
return nil
}
return err
}
@@ -84,6 +88,10 @@ func applyManifest(ctx context.Context, cluster *models.Cluster, manifest string
}
force := true
if _, err := resource.Patch(ctx, name, types.ApplyPatchType, data, metav1.PatchOptions{FieldManager: "lokideploy", Force: &force}); err != nil {
// 命名空间级 Token 无权 patch Namespace:视为已由管理员预建,跳过
if obj.GetKind() == "Namespace" && apierrors.IsForbidden(err) {
continue
}
return fmt.Errorf("应用 %s %s/%s: %w", obj.GetKind(), obj.GetNamespace(), name, err)
}
}
+49 -18
View File
@@ -319,32 +319,63 @@ func DeleteLogSource(id uint) error {
return fmt.Errorf("数据源不存在")
}
// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host-binary→url;k8s→cluster-svc)
// EnsureLogSourceForDeployment 部署成功后自动注册查询数据源(host→url;k8s→cluster-svc)。
// 已存在同名数据源时:平台托管的自动刷新连接信息,用户手填的不覆盖。
func EnsureLogSourceForDeployment(d *models.LokiDeployment) {
if d == nil || d.Endpoint == "" {
return
}
svc := "loki"
port := 3100
target := &models.LogSource{
Name: d.Name, TargetType: "cluster-svc",
ClusterID: d.ClusterID, Namespace: d.Namespace, Service: svc, Port: port,
AuthType: "none", Managed: true,
}
if d.Mode == "host-binary" || d.Mode == "host-docker" {
target.TargetType = "url"
target.URL = d.Endpoint
target.ClusterID = nil
}
db := repository.GetDB()
db.Lock()
for i := range db.LogSources {
if db.LogSources[i].Name != target.Name {
continue
}
if !db.LogSources[i].Managed {
db.Unlock()
return
}
db.LogSources[i].TargetType = target.TargetType
db.LogSources[i].ClusterID = target.ClusterID
db.LogSources[i].Namespace = target.Namespace
db.LogSources[i].Service = target.Service
db.LogSources[i].Port = target.Port
db.LogSources[i].URL = target.URL
db.LogSources[i].UpdatedAt = time.Now()
db.Unlock()
db.RequestSave()
return
}
db.Unlock()
_, _ = CreateLogSource(target)
}
// EnsureLogSourcesForRunningDeployments 补齐运行中部署的数据源,避免查询页默认没有数据源。
func EnsureLogSourcesForRunningDeployments() {
db := repository.GetDB()
db.RLock()
for i := range db.LogSources {
if db.LogSources[i].Name == d.Name {
db.RUnlock()
return
running := make([]models.LokiDeployment, 0, len(db.Deployments))
for i := range db.Deployments {
d := db.Deployments[i]
if d.Status == "running" && d.Endpoint != "" {
running = append(running, d)
}
}
db.RUnlock()
svc := "loki"
port := 3100
src := &models.LogSource{
Name: d.Name, TargetType: "cluster-svc",
ClusterID: d.ClusterID, Namespace: d.Namespace, Service: svc, Port: port,
AuthType: "none",
for i := range running {
EnsureLogSourceForDeployment(&running[i])
}
if d.Mode == "host-binary" || d.Mode == "host-docker" {
src.TargetType = "url"
src.URL = d.Endpoint
src.ClusterID = nil
}
_, _ = CreateLogSource(src)
}
+23 -10
View File
@@ -113,10 +113,11 @@ func (d *LokiDeployer) generateLokiConfig() string {
compactorStore := "filesystem"
switch storageType {
case "s3", "oss", "minio":
case "s3", "oss", "minio", "cos", "obs":
objectStore = "s3"
rulerStore = "s3"
compactorStore = "s3"
// 删除请求存本地盘,避免各云 S3 兼容实现差异导致 compactor 启动失败
compactorStore = "filesystem"
case "gcs":
objectStore = "gcs"
rulerStore = "gcs"
@@ -201,13 +202,19 @@ func (d *LokiDeployer) buildStorageConfigBlock() string {
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, endpoint, sc.Region, accessKey, secretKey, insecure)
case "oss":
return fmt.Sprintf(` alibabacloud:
bucket: %s
case "oss", "cos", "obs":
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` aws:
bucketnames: %s
endpoint: %s
region: %s
access_key_id: %s
secret_access_key: %s`, sc.Bucket, sc.Endpoint, sc.Region, accessKey, secretKey)
secret_access_key: %s
insecure: %s
s3forcepathstyle: false`, sc.Bucket, sc.Endpoint, sc.Region, accessKey, secretKey, insecure)
case "minio":
insecure := "false"
if sc.Insecure {
@@ -259,13 +266,19 @@ func (d *LokiDeployer) buildRulerStorageBlock() string {
secret_access_key: %s
insecure: %s
s3forcepathstyle: true`, sc.Bucket, endpoint, sc.Region, accessKey, secretKey, insecure)
case "oss":
case "oss", "cos", "obs":
insecure := "false"
if sc.Insecure {
insecure = "true"
}
return fmt.Sprintf(` s3:
bucketnames: %s
endpoint: %s
region: %s
access_key_id: %s
secret_access_key: %s
s3forcepathstyle: true`, sc.Bucket, sc.Endpoint, accessKey, secretKey)
insecure: %s
s3forcepathstyle: false`, sc.Bucket, sc.Endpoint, sc.Region, accessKey, secretKey, insecure)
case "minio":
insecure := "false"
if sc.Insecure {
@@ -341,8 +354,8 @@ compactor:
retention_delete_delay: 2h
limits_config:
retention_period: %dh
ingestion_rate_mb: 16
ingestion_burst_size_mb: 32
ingestion_rate_mb: 64
ingestion_burst_size_mb: 128
schema_config:
configs:
- from: "2024-01-01"
+1 -1
View File
@@ -21,7 +21,7 @@ func dockerLokiImage(version string) (string, error) {
}
for _, supported := range LokiVersionList {
if supported == version && strings.HasPrefix(version, "3.") {
return "grafana/loki:" + version, nil
return ResolveImageFor("loki", "grafana/loki", version), nil
}
}
return "", fmt.Errorf("主机 Docker 部署请选择支持的 Loki 3.x 版本")
+5 -5
View File
@@ -9,14 +9,14 @@ import (
func (d *LokiDeployer) generateManifest(ns string) string {
version := d.effectiveVersion()
image := ResolveImage("docker.io/grafana/loki:" + version)
image := ResolveImageFor("loki", "grafana/loki", version)
replicas := d.deployment.Replicas
if replicas < 1 {
replicas = 1
}
storageSize := d.deployment.StorageSize
if storageSize == "" {
storageSize = "10Gi"
storageSize = "20Gi"
}
cpu := d.deployment.ResourcesCPU
@@ -139,7 +139,7 @@ spec:
fsGroup: 0
containers:
- name: loki
image: grafana/loki:%s
image: %s
imagePullPolicy: IfNotPresent
args:
- -config.file=/etc/loki/loki.yaml%s%s
@@ -155,8 +155,8 @@ spec:
cpu: "%s"
memory: "%s"
requests:
cpu: "200m"
memory: "256Mi"
cpu: "500m"
memory: "512Mi"
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: false
@@ -0,0 +1,52 @@
package services
import (
"fmt"
"strings"
"testing"
"opsxloki/models"
)
// TestObjectStoreConfigs 验证 OSS / COS / OBS 生成的 Loki 配置:
// 走 S3 兼容(object_store: s3 + bucketnames),虚拟主机风格,compactor 用本地盘。
func TestObjectStoreConfigs(t *testing.T) {
for _, typ := range []string{"oss", "cos", "obs"} {
t.Run(typ, func(t *testing.T) {
d := &models.LokiDeployment{
Name: "test-loki",
Namespace: "log",
Mode: "k8s-yaml",
Replicas: 1,
RetentionDays: 15,
StorageConfig: &models.StorageConfig{
StorageType: typ,
Bucket: "test-bucket",
Endpoint: "oss-cn-hongkong.aliyuncs.com",
Region: "oss-cn-hongkong",
AccessKey: "AKTEST",
SecretKey: "SKTEST",
PathPrefix: "loki",
},
}
cfg := NewLokiDeployer(d, func(level, message, detail string) {}).generateLokiConfig()
fmt.Printf("---- %s ----\n%s\n", typ, cfg)
if strings.Contains(cfg, "alibabacloud") {
t.Fatal("storage_config still contains alibabacloud")
}
for _, want := range []string{
"object_store: s3",
"bucketnames: test-bucket",
"endpoint: oss-cn-hongkong.aliyuncs.com",
"region: oss-cn-hongkong",
"s3forcepathstyle: false",
"delete_request_store: filesystem",
} {
if !strings.Contains(cfg, want) {
t.Fatalf("missing %q", want)
}
}
})
}
}
+2 -2
View File
@@ -64,7 +64,7 @@ services:
dockerfile: Dockerfile
args:
TARGETARCH: ${TARGETARCH:-amd64}
image: opsxloki:v2
image: lghost/opsxloki:latest
container_name: opsxloki-1
hostname: opsxloki-1
restart: unless-stopped
@@ -145,7 +145,7 @@ services:
dockerfile: Dockerfile
args:
TARGETARCH: ${TARGETARCH:-amd64}
image: opsxloki:v2
image: lghost/opsxloki:latest
container_name: opsxloki-2
hostname: opsxloki-2
restart: unless-stopped
+1 -1
View File
@@ -20,7 +20,7 @@ services:
dockerfile: Dockerfile
args:
TARGETARCH: ${TARGETARCH:-amd64}
image: opsxloki:v2
image: lghost/opsxloki:latest
container_name: opsxloki
hostname: opsxloki
+46
View File
@@ -0,0 +1,46 @@
# 部署说明
本文档描述 `opsx-loki` 的两种部署方式,域名与 IP 均为脱敏示例:
- `opsx-loki.xxx.io` —— 平台 UI 域名
- `log.xxx.io` —— Loki 查询域名(Ingress)
- `192.168.x.x` —— 内网地址占位
## 架构
```
浏览器
│ HTTPS(WAF / SLB 终止)
▼
边缘 WAF ──► 主机 nginx(192.168.x.x)
│
├─ 主机部署:proxy_pass http://127.0.0.1:8081
└─ K8s 部署:proxy_pass http://<NodeIP>:30080
│
▼
Service opsx-loki:8080
│
▼
Deployment opsx-loki(1 副本)
│
┌────────────────┴────────────────┐
▼ ▼
PVC /app/data Loki Service log/loki:3100
(JSON DB、kubeconfig 加密) (日志存储:S3/OSS/COS/OBS)
```
## 两种方式的取舍
| | 主机 Docker | Kubernetes |
|---|---|---|
| 适用 | 单机、快速验证 | 多环境、统一调度 |
| 数据 | 本地卷 `/app/data` | PVC(默认 StorageClass) |
| 域名 | 主机 nginx 反代 `127.0.0.1:8081` | NodePort + nginx,或 ALB Ingress |
| 约束 | 单实例 | **1 副本 + Recreate**(JSON DB 单写者) |
## 文档索引
| 文档 | 内容 |
|---|---|
| [host-nginx.md](host-nginx.md) | 主机部署的 nginx 反代配置(含 WAF 真实 IP、白名单) |
| [kubernetes.md](kubernetes.md) | K8s 清单、Ingress(ALB)、PVC、Secret、升级 |
+69
View File
@@ -0,0 +1,69 @@
# 主机部署:nginx 反代
适用于 `docker compose` 单机部署。平台监听 `127.0.0.1:8081`(或容器映射端口),由主机 nginx 对外提供 HTTP/HTTPS。
> 域名、IP 已脱敏;实际使用时替换为你的值。
## nginx vhost
`/etc/nginx/conf.d/opsx-loki.xxx.io.conf`:
```nginx
server {
listen 80;
server_name opsx-loki.xxx.io;
access_log /var/log/nginx/opsx-loki.xxx.io_access.log main;
error_log /var/log/nginx/opsx-loki.xxx.io_error.log;
location / {
proxy_pass http://127.0.0.1:8081;
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_read_timeout 300s;
proxy_send_timeout 300s;
client_max_body_size 100m;
}
}
```
## 前置 WAF / SLB 时(可选)
WAF 会把源 IP 改写为内网地址,需要在 `nginx.conf` 的 `http {}` 中还原真实客户端 IP,否则白名单会失效:
```nginx
set_real_ip_from 192.168.x.x; # WAF 回源地址
real_ip_header X-Forwarded-For;
```
## 内网白名单(可选)
建议统一放在 `/etc/nginx/nginx.conf` 的 `http {}`,不要散落在各 vhost(vhost 内一旦出现 `allow/deny` 会切断继承):
```nginx
allow 192.168.0.0/16;
allow 203.0.113.10; # 办公/跳板机出口
deny all;
```
公网站点单独在其 vhost 内写 `allow all;`。
## 生效
```bash
nginx -t && nginx -s reload
```
## 验证
```bash
curl -I -H "Host: opsx-loki.xxx.io" http://127.0.0.1/
curl -s -X POST http://opsx-loki.xxx.io/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"<你的密码>"}'
```
+238
View File
@@ -0,0 +1,238 @@
# Kubernetes 部署
清单在仓库 `kubernetes/` 目录,域名与 IP 已脱敏。
## 1. 命名空间与密钥
```bash
kubectl create namespace log # 已存在可跳过
kubectl -n log create secret generic opsxloki-secrets \
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
--from-literal=ENCRYPTION_KEY="$(openssl rand -hex 32)"
```
> `ENCRYPTION_KEY` 必须固定保存:DB 里的 kubeconfig、对象存储 AK/SK、SSH 私钥都用它加密,丢失后无法解密。
> 从旧实例迁移时,必须使用**原实例相同的** `JWT_SECRET` / `ENCRYPTION_KEY`。
## 2. 数据卷
`kubernetes/pvc.yaml`:
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: opsxloki-data
namespace: log
spec:
accessModes: [ReadWriteOnce]
resources:
requests:
storage: 30Gi
```
不写 `storageClassName`,使用集群默认 StorageClass。没有默认 SC 时按云填写:
| 云 | StorageClass |
|---|---|
| 阿里云 ACK | `alicloud-disk-essd` |
| AWS EKS | `gp3` |
| 腾讯云 TKE | `cbs` |
| 华为云 CCE | `csi-disk` |
## 3. 工作负载
```bash
kubectl apply -f kubernetes/namespace.yaml \
-f kubernetes/pvc.yaml \
-f kubernetes/deployment.yaml \
-f kubernetes/service.yaml
```
关键约束(`deployment.yaml` 已内置):
- `replicas: 1`:JSON DB 单写者,多副本会互相覆盖。
- `strategy: Recreate`:滚动更新会同时挂同一个 RWO PVC。
- `DATA_DIR=/app/data`,挂 PVC;`runAsUser/fsGroup: 1000`。
- 镜像 `lghost/opsxloki:latest`,`imagePullPolicy: Always`。
- 探针:`/api/v1/health`。
## 4. Service
```yaml
apiVersion: v1
kind: Service
metadata:
name: opsx-loki
namespace: log
spec:
type: ClusterIP
selector:
app: opsx-loki
ports:
- name: http
port: 8080
targetPort: http
```
需要主机 nginx 反代时,额外创建 NodePort(`kubernetes/service-nodeport.yaml`,30080)。
## 5. Ingress(ALB)
`log.xxx.io` 指向 Loki/平台入口,使用阿里云 ALB IngressClass:
```yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: loki.xxx.io
namespace: log
labels:
app: opsx-loki
spec:
ingressClassName: alb
rules:
- host: log.xxx.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: opsx-loki
port:
number: 8080
```
```bash
kubectl apply -f kubernetes/ingress.yaml
kubectl -n log get ingress
```
ALB 会分配一个地址(如 `alb-xxxx.<region>.alb.aliyuncsslbintl.com`),把域名的 CNAME 指过去即可。
> 其他云把 `ingressClassName` 换成对应实现:TKE `qcloud`、CCE `nginx`/`cce`、自建 `nginx`。
## 6. 数据迁移(从主机/旧实例)
JSON DB 是单文件,迁移时先停旧实例,再写入 PVC:
```bash
# 1) 缩容到 0,避免应用关机时覆盖
kubectl -n log scale deploy/opsx-loki --replicas=0
# 2) 临时 Pod 挂 PVC 拷数据(与业务 Pod 同节点,RWO 盘才能挂上)
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Pod
metadata: {name: pvc-loader, namespace: log}
spec:
restartPolicy: Never
securityContext: {runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000}
containers:
- name: loader
image: busybox:1.36
command: ["sleep", "1800"]
volumeMounts: [{name: data, mountPath: /data}]
volumes:
- name: data
persistentVolumeClaim: {claimName: opsxloki-data}
EOF
kubectl -n log cp ./opsxloki.json pvc-loader:/data/opsxloki.json
kubectl -n log cp ./known_hosts pvc-loader:/data/known_hosts
kubectl -n log delete pod pvc-loader
kubectl -n log scale deploy/opsx-loki --replicas=1
```
> 不要在业务 Pod 运行时 `kubectl cp` 覆盖 DB:应用退出时会用内存数据回写,覆盖你拷进去的文件。
## 7. 验证
```bash
kubectl -n log get pods,pvc,svc,ingress
kubectl -n log logs deploy/opsx-loki --tail=20
kubectl -n log exec deploy/opsx-loki -- wget -qO- http://127.0.0.1:8080/api/v1/health
```
## 8. 升级
```bash
kubectl -n log set image deploy/opsx-loki opsx-loki=lghost/opsxloki:v0.2
kubectl -n log rollout status deploy/opsx-loki
```
`Recreate` 会先停旧 Pod 再起新 Pod,期间平台短暂不可用(单写者约束的代价)。
## 9. Token 接入(长期有效,仅 `log` 命名空间)
平台支持 ServiceAccount Token 接入。**推荐使用长期 Token + 命名空间级 Role**:
Token 不过期,但权限只覆盖 `log` 命名空间,泄露影响面同样仅限于 `log`,不会波及其他命名空间。
在集群里执行一次(管理员权限),然后取 Token:
```bash
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Namespace
metadata:
name: log
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: opsx-loki
namespace: log
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: opsx-loki
namespace: log
rules:
- apiGroups: [""]
resources: ["pods", "pods/log", "services", "configmaps", "secrets", "persistentvolumeclaims", "events", "serviceaccounts"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["statefulsets", "daemonsets", "deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: opsx-loki
namespace: log
subjects:
- kind: ServiceAccount
name: opsx-loki
namespace: log
roleRef:
kind: Role
name: opsx-loki
apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Secret
metadata:
name: opsx-loki-token
namespace: log
annotations:
kubernetes.io/service-account.name: opsx-loki
type: kubernetes.io/service-account-token
EOF
kubectl -n log get secret opsx-loki-token -o jsonpath='{.data.token}' | base64 -d
```
- 这个 Token **不会自动过期**,但只绑定 `log` 命名空间的 Role:能操作该命名空间的 Pod、日志、工作负载、Service、ConfigMap、Secret、PVC,**无权访问其他命名空间和集群级资源**。
- 平台展示节点数需要 `nodes` 只读权限;命名空间级 Token 没有该权限时,平台会跳过节点信息,不影响集群接入与部署。
- 平台收到 Token 后会转成 kubeconfig,并用 `ENCRYPTION_KEY` 加密存 DB(`ENC:` 前缀),接口不返回明文。
- 轮换:删除 Secret 后重建,旧 Token 立即失效。
- 如果集群策略要求短期凭证,可改用 `kubectl -n log create token opsx-loki --duration=24h` 定期轮换。
- 上面的 Namespace 由管理员创建,平台 Token 本身没有集群级权限;只有确实需要平台自建命名空间时,才额外授予 `namespaces: create/get` 的 ClusterRole(create 无法按名称限制,预建 `log` 更安全)。
+7 -1
View File
@@ -1,9 +1,15 @@
// 设置域 API — 镜像源等平台级配置
import { http } from '../http'
export interface ComponentImage {
repository: string
tag: string
}
export interface ImageRegistryConfig {
mode: 'overseas' | 'cn' | 'custom'
customHost: string
images: Record<string, ComponentImage>
sampleLoki: string
sampleGrafana: string
samplePromtail: string
@@ -12,6 +18,6 @@ export interface ImageRegistryConfig {
export const settingsApi = {
getImageRegistry: () => http.get<ImageRegistryConfig>('/settings/image-registry'),
saveImageRegistry: (data: { mode: string; customHost?: string }) =>
saveImageRegistry: (data: { mode: string; customHost?: string; images?: Record<string, ComponentImage> }) =>
http.put<ImageRegistryConfig>('/settings/image-registry', data),
}
+1 -1
View File
@@ -4,7 +4,7 @@ import type { PageData, StorageConfig } from '@/types';
export interface StorageCreateInput {
name: string;
storageType: 's3' | 'oss' | 'minio' | 'rustfs';
storageType: 's3' | 'oss' | 'cos' | 'obs' | 'minio' | 'rustfs';
endpoint?: string;
bucket: string;
region?: string;
+1 -1
View File
@@ -58,7 +58,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[90vh] overflow-y-auto translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
+71
View File
@@ -38,6 +38,49 @@ const DEFAULTS: RuleForm = {
windowMin: '15', threshold: '0', cooldownMin: '60', enabled: true,
}
interface AlertTemplate {
label: string
desc: string
query: string
windowMin: number
threshold: number
cooldownMin: number
}
/** 常用告警模板 — 只做预填,应用后可改 namespace / 阈值 / 窗口 */
const TEMPLATES: AlertTemplate[] = [
{
label: '业务 NS error',
desc: '指定命名空间(默认 astralx)内 error 日志',
query: '{namespace="astralx"} |~ "(?i)error"',
windowMin: 5, threshold: 10, cooldownMin: 30,
},
{
label: '全 NS error > 1000',
desc: '所有命名空间 error 日志总量超过 1000 条',
query: '{namespace=~".+"} |~ "(?i)error"',
windowMin: 15, threshold: 1000, cooldownMin: 60,
},
{
label: 'Ingress 5xx',
desc: 'ingress-nginx 5xx(需 JSON access log,平台可一键标准化)',
query: '{namespace="ingress-nginx"} | json | status >= 500',
windowMin: 5, threshold: 5, cooldownMin: 15,
},
{
label: 'Ingress 4xx',
desc: 'ingress-nginx 4xx(需 JSON access log)',
query: '{namespace="ingress-nginx"} | json | status >= 400 | status < 500',
windowMin: 5, threshold: 50, cooldownMin: 30,
},
{
label: 'FATAL',
desc: '所有命名空间出现 FATAL 即告警',
query: '{namespace=~".+"} |= "FATAL"',
windowMin: 5, threshold: 0, cooldownMin: 15,
},
]
/** 告警规则页:规则 CRUD + 立即检查 + 发送历史 */
const AlertsPage = memo(function AlertsPage() {
const [rules, setRules] = useState<AlertRule[]>([])
@@ -87,6 +130,14 @@ const AlertsPage = memo(function AlertsPage() {
setOpen(true)
}
const applyTemplate = (t: AlertTemplate) => {
form.setValue('name', t.label)
form.setValue('query', t.query)
form.setValue('windowMin', String(t.windowMin))
form.setValue('threshold', String(t.threshold))
form.setValue('cooldownMin', String(t.cooldownMin))
}
const onSubmit = form.handleSubmit(async (v) => {
setSubmitting(true)
try {
@@ -244,6 +295,26 @@ const AlertsPage = memo(function AlertsPage() {
</DialogHeader>
<Form {...form}>
<form onSubmit={onSubmit} className="space-y-4">
{!editing && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">快捷模板(点击预填,可再修改)</p>
<div className="flex flex-wrap gap-2">
{TEMPLATES.map((t) => (
<Button
key={t.label}
type="button"
variant="outline"
size="sm"
className="h-8 text-xs"
title={`${t.desc}\n${t.query}`}
onClick={() => applyTemplate(t)}
>
{t.label}
</Button>
))}
</div>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<FormField control={form.control} name="name" render={({ field }) => (
<FormItem>
@@ -247,6 +247,89 @@ export const ClusterFormDialog = memo(function ClusterFormDialog({
</FormItem>
)}
/>
<details className="min-w-0 overflow-hidden rounded-md border bg-muted/30 px-3 py-2 text-xs">
<summary className="cursor-pointer select-none font-medium">
最小授权建议(点击展开)
</summary>
<div className="mt-2 min-w-0 space-y-2 break-words text-muted-foreground">
<p>
只授予 <code>log</code> 命名空间权限,Token 泄露也不会影响其他命名空间;
<strong>不要使用 cluster-admin</strong>。
</p>
<p>
一次性创建 <code>log</code> 命名空间、ServiceAccount、命名空间级 Role 和长期 Token
(在集群里执行一次,Token 粘贴到下方输入框):
</p>
<pre className="max-w-full overflow-x-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded bg-background p-2 font-mono text-[11px] leading-relaxed">{`# 1) 一次创建:命名空间 + SA + 命名空间级 Role + 长期 Token
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Namespace
metadata:
name: log
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: opsx-loki
namespace: log
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: opsx-loki
namespace: log
rules:
- apiGroups: [""]
resources: ["pods", "pods/log", "services", "configmaps", "secrets", "persistentvolumeclaims", "events", "serviceaccounts"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["apps"]
resources: ["statefulsets", "daemonsets", "deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["networking.k8s.io"]
resources: ["ingresses"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: opsx-loki
namespace: log
subjects:
- kind: ServiceAccount
name: opsx-loki
namespace: log
roleRef:
kind: Role
name: opsx-loki
apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Secret
metadata:
name: opsx-loki-token
namespace: log
annotations:
kubernetes.io/service-account.name: opsx-loki
type: kubernetes.io/service-account-token
EOF
# 2) 取出长期 Token(不自动过期),粘贴到 Token 输入框
kubectl -n log get secret opsx-loki-token -o jsonpath='{.data.token}' | base64 -d`}</pre>
<p>
上面的 Namespace 由管理员创建,平台 Token 本身没有任何集群级权限。
只有确实需要平台自建命名空间时,才额外授予
<code>namespaces: create/get</code> 的 ClusterRole;
注意 namespace 的 create 无法按名称限制,所以预建 <code>log</code> 更安全。
</p>
<p>
Token 长期有效,但只绑定 <code>log</code> 命名空间的 Role
(仅能操作该命名空间的 Pod / 工作负载),泄露影响面仅限于 <code>log</code>;
服务端会转成 kubeconfig 并用 <code>ENCRYPTION_KEY</code> 加密存储,
数据库里只有 <code>ENC:</code> 密文,接口不返回明文。
轮换时删除该 Secret 重建即可,旧 Token 立即失效。
</p>
</div>
</details>
</TabsContent>
</Tabs>
</FormControl>
@@ -1,4 +1,5 @@
import { memo, useEffect, useState } from 'react'
import { Link } from 'react-router'
import { Play, Square, RotateCw, Trash2, Terminal, ExternalLink, Pencil, Rocket } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { StatusBadge } from '@/components/StatusBadge'
@@ -91,7 +92,12 @@ export const DeploymentCard = memo(function DeploymentCard({
</a>
) }] : []),
]}
message={d.statusMessage || undefined}
message={
d.statusMessage ||
(d.status === 'running'
? '提示:到「日志查询 → 数据源」关联 Loki 后即可查询;跨集群/公网访问需先为 Loki 配置 Ingress 域名。'
: undefined)
}
actions={
<>
<Button variant="outline" size="sm" className="text-xs" onClick={() => onEditRetention(d)} disabled={isDeploying || isBusy}>
@@ -115,6 +121,16 @@ export const DeploymentCard = memo(function DeploymentCard({
</Button>
</>
)}
{d.status === 'running' && (
<>
<Button variant="outline" size="sm" className="text-xs" asChild>
<Link to="/logs">日志查询</Link>
</Button>
<Button variant="outline" size="sm" className="text-xs" asChild>
<Link to="/collectors">采集器</Link>
</Button>
</>
)}
<Button variant="outline" size="sm" className="text-xs" onClick={() => onViewLogs(d)}>
<Terminal className="size-3 mr-1" />日志
</Button>
@@ -347,7 +347,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormItem>
<FormLabel>存储容量</FormLabel>
<FormControl>
<Input placeholder="10Gi" {...field} />
<Input placeholder="20Gi" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -363,7 +363,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormItem>
<FormLabel>CPU</FormLabel>
<FormControl>
<Input placeholder="500m" {...field} />
<Input placeholder="1" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -376,7 +376,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
<FormItem>
<FormLabel>内存</FormLabel>
<FormControl>
<Input placeholder="512Mi" {...field} />
<Input placeholder="2Gi" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -98,7 +98,7 @@ export const RetentionDialog = memo(function RetentionDialog({ deployment, onClo
{liveConfig && (
<div>
<p className="mb-2 text-xs text-muted-foreground">{liveConfig.location}</p>
<pre className="max-h-[45vh] overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs whitespace-pre">{liveConfig.yaml}</pre>
<pre className="max-h-[45vh] max-w-full overflow-auto rounded-md bg-muted/50 p-3 font-mono text-xs whitespace-pre">{liveConfig.yaml}</pre>
</div>
)}
</div>
+21 -4
View File
@@ -15,6 +15,17 @@ const isPositiveInt = (v: string, min: number, max?: number) => {
return Number.isInteger(n) && n >= min && (max === undefined || n <= max)
}
// 解析存储容量为 GiB(支持 Gi/Mi/Ti),无法解析返回 null
const parseGiB = (v: string): number | null => {
const m = /^(\d+(?:\.\d+)?)\s*(Gi|Mi|Ti)$/i.exec(v.trim())
if (!m) return null
const n = Number(m[1])
const unit = m[2].toLowerCase()
if (unit === 'ti') return n * 1024
if (unit === 'mi') return n / 1024
return n
}
export const deploymentFormSchema = z
.object({
name: z.string().min(1, '请输入部署名称'),
@@ -36,7 +47,7 @@ export const deploymentFormSchema = z
.refine((v) => isPositiveInt(v, 1, 10), '副本数需为 1-10 之间的整数'),
resourcesCPU: z.string().min(1, '请填写 CPU 配额(如 500m)'),
resourcesMem: z.string().min(1, '请填写内存配额(如 512Mi)'),
storageSize: z.string().min(1, '请填写存储容量(如 10Gi)'),
storageSize: z.string().min(1, '请填写存储容量(如 20Gi)'),
})
.superRefine((data, ctx) => {
if (isK8sMode(data.mode) && !data.clusterID) {
@@ -51,6 +62,12 @@ export const deploymentFormSchema = z
if (isK8sMode(data.mode) && !data.namespace.trim()) {
ctx.addIssue({ code: 'custom', path: ['namespace'], message: '请填写命名空间' })
}
if (isK8sMode(data.mode)) {
const gi = parseGiB(data.storageSize)
if (gi === null || gi < 20) {
ctx.addIssue({ code: 'custom', path: ['storageSize'], message: '阿里云 ESSD 最小 20Gi,请填 20Gi 或以上' })
}
}
})
export type DeploymentFormValues = z.infer<typeof deploymentFormSchema>
@@ -69,7 +86,7 @@ export const DEFAULT_VALUES: DeploymentFormValues = {
authEnabled: false,
retentionDays: '30',
replicas: '1',
resourcesCPU: '500m',
resourcesMem: '512Mi',
storageSize: '10Gi',
resourcesCPU: '1',
resourcesMem: '2Gi',
storageSize: '20Gi',
}
@@ -20,7 +20,7 @@ function ContextRow({ row, current, terms }: { row: LogEntry; current?: boolean;
export function LogContextDialog({ context, terms, onClose }: { context: LogContext | null; terms: string[]; onClose: () => void }) {
return <Dialog open={context !== null} onOpenChange={open => { if (!open) onClose() }}>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-4xl">
<DialogContent className="flex max-h-[85vh] flex-col gap-0 !overflow-hidden p-0 sm:max-w-4xl">
<DialogHeader className="border-b px-5 py-4 pr-12">
<DialogTitle>同一日志流的前后文</DialogTitle>
<DialogDescription>{context?.row.labels.filename ? '同一文件' : '同一日志流'} · 前后各最多 5 条 · 前后 5 分钟</DialogDescription>
@@ -24,7 +24,7 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
const [filter, setFilter] = useState('')
const [loadingLabels, setLoadingLabels] = useState(false)
const [loadingValues, setLoadingValues] = useState(false)
const [scoped, setScoped] = useState(false)
const [scoped, setScoped] = useState(true)
const selected = useMemo(() => currentLabels(query), [query])
const startNs = Number.isFinite(new Date(start).getTime()) ? new Date(start).getTime() * 1e6 : undefined
const endNs = Number.isFinite(new Date(end).getTime()) ? new Date(end).getTime() * 1e6 : undefined
@@ -32,7 +32,20 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
useEffect(() => {
if (!open || !sourceId) return
let active = true
logsApi.labels(sourceId, startNs, endNs).then(next => { if (active) setLabels(next) }).catch(() => { if (active) setLabels([]) }).finally(() => { if (active) setLoadingLabels(false) })
logsApi.labels(sourceId, startNs, endNs).then(next => {
if (!active) return
// namespace 优先,其次 app/pod/container,其余按字母序
const order = ['namespace', 'app', 'service_name', 'pod', 'container', 'job']
const ordered = [...next].sort((a, b) => {
const ia = order.indexOf(a), ib = order.indexOf(b)
return (ia < 0 ? 99 : ia) - (ib < 0 ? 99 : ib) || a.localeCompare(b)
})
setLabels(ordered)
if (!activeLabel) {
const first = ordered.includes('namespace') ? 'namespace' : ordered[0] || ''
if (first) { setActiveLabel(first); setLoadingValues(true) }
}
}).catch(() => { if (active) setLabels([]) }).finally(() => { if (active) setLoadingLabels(false) })
return () => { active = false }
}, [open, sourceId, startNs, endNs])
@@ -48,6 +61,16 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
const shown = values.filter(value => value.toLowerCase().includes(filter.toLowerCase())).slice(0, 200)
// 选 namespace 时重置 selector(清掉旧 app/pod,避免跨 namespace 残留条件),保留后续管道
const choose = (label: string, value: string) => {
if (label === 'namespace') {
const j = query.indexOf('}')
onQueryChange(`{namespace=${JSON.stringify(value)}}${j >= 0 ? query.slice(j + 1) : ''}`)
} else {
onQueryChange(applyLabelFilter(query, label, value))
}
}
return <section className="overflow-hidden rounded-lg border bg-card" aria-label="Label 浏览器">
<button type="button" className="flex w-full items-center gap-2 px-4 py-3 text-left text-sm font-medium hover:bg-muted/50" aria-expanded={open} onClick={() => { setOpen(!open); if (!open) setLoadingLabels(true) }}>
{open ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
@@ -61,9 +84,9 @@ export function LokiLabelBrowser({ sourceId, query, onQueryChange, start, end }:
<div className="min-w-0 p-3">
{!activeLabel ? <p className="py-12 text-center text-sm text-muted-foreground">选择左侧标签,再搜索对应的值</p> : <><div className="mb-3 flex flex-wrap items-center gap-2"><strong className="text-sm">{activeLabel} 的值</strong><Input aria-label={`搜索 ${activeLabel} 的值`} className="h-8 max-w-sm" placeholder="搜索或输入标签值" value={filter} onChange={event => setFilter(event.target.value)} /></div>
<label className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground"><input type="checkbox" checked={scoped} onChange={event => setScoped(event.target.checked)} />按当前查询条件缩小候选</label>
<div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">加载中…</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">没有匹配值</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => onQueryChange(applyLabelFilter(query, activeLabel, value))} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div>
{filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => onQueryChange(applyLabelFilter(query, activeLabel, filter.trim()))}>使用“{filter.trim()}”作为值</button>}
<p className="mt-2 text-xs text-muted-foreground">候选来自当前时间范围内已写入 Loki 的日志;Kubernetes 存在的命名空间未必有日志。授权范围仍由服务端限制。</p>
<div className="grid max-h-48 gap-1 overflow-auto sm:grid-cols-2 xl:grid-cols-3">{loadingValues ? <p className="p-2 text-sm text-muted-foreground">加载中…</p> : shown.length === 0 ? <p className="p-2 text-sm text-muted-foreground">没有匹配值</p> : shown.map(value => <button key={value} type="button" title={value} onClick={() => choose(activeLabel, value)} className={`truncate rounded px-2 py-1.5 text-left font-mono text-sm ${selected[activeLabel] === value ? 'bg-success/10 text-success' : 'hover:bg-muted'}`}>{value}</button>)}</div>
{filter.trim() && !values.includes(filter.trim()) && <button type="button" className="mt-2 rounded border px-2 py-1 text-xs text-primary hover:bg-muted" onClick={() => choose(activeLabel, filter.trim())}>使用“{filter.trim()}”作为值</button>}
<p className="mt-2 text-xs text-muted-foreground">先选 namespace 可查该命名空间全部副本;再选 app / service_name 可缩小到某个服务(无 app 标签的工作负载用 namespace 即可)。候选来自当前时间范围内已写入 Loki 的日志;授权范围仍由服务端限制。</p>
</>}
</div>
</div>}
@@ -6,36 +6,69 @@ import { applyLabelFilter, extractSelector } from '../query-utils'
type Template = 'app' | 'error' | 'warn' | 'pod' | 'ingress'
function selectorOf(labels: Record<string, string>) {
const entries = Object.entries(labels).filter(([, value]) => value.trim())
if (entries.length === 0) return ''
return `{${entries.map(([key, value]) => `${key}=${JSON.stringify(value.trim())}`).join(',')}}`
}
export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: {
sourceId: number
query: string
onQueryChange: (query: string) => void
compact?: boolean
}) {
const [app, setApp] = useState('')
const [namespace, setNamespace] = useState('')
const [app, setApp] = useState('')
const [pod, setPod] = useState('')
const [apps, setApps] = useState<string[]>([])
const [namespaces, setNamespaces] = useState<string[]>([])
const [apps, setApps] = useState<string[]>([])
const [pods, setPods] = useState<string[]>([])
// namespace 列表只加载一次
useEffect(() => {
if (!sourceId) return
let active = true
Promise.allSettled([
logsApi.labelValues(sourceId, 'app'),
logsApi.labelValues(sourceId, 'namespace'),
logsApi.labelValues(sourceId, 'pod'),
]).then(([nextApps, nextNamespaces, nextPods]) => {
if (active) {
setApps(nextApps.status === 'fulfilled' ? nextApps.value : [])
setNamespaces(nextNamespaces.status === 'fulfilled' ? nextNamespaces.value : [])
setPods(nextPods.status === 'fulfilled' ? nextPods.value : [])
}
})
logsApi.labelValues(sourceId, 'namespace')
.then(next => { if (active) setNamespaces(next) })
.catch(() => { if (active) setNamespaces([]) })
return () => { active = false }
}, [sourceId])
// app 候选:选了 namespace 就只在该命名空间内搜索
useEffect(() => {
if (!sourceId) return
let active = true
const selector = selectorOf({ namespace })
logsApi.labelValues(sourceId, 'app', selector)
.then(next => { if (active) setApps(next) })
.catch(() => { if (active) setApps([]) })
return () => { active = false }
}, [sourceId, namespace])
// pod 候选:按 namespace + app 收窄
useEffect(() => {
if (!sourceId) return
let active = true
const selector = selectorOf({ namespace, app })
logsApi.labelValues(sourceId, 'pod', selector)
.then(next => { if (active) setPods(next) })
.catch(() => { if (active) setPods([]) })
return () => { active = false }
}, [sourceId, namespace, app])
function chooseNamespace(value: string) {
setNamespace(value)
// 换命名空间后旧的 app / pod 条件失效
setApp('')
setPod('')
}
function chooseApp(value: string) {
setApp(value)
setPod('')
}
function generate(template: Template) {
let next = extractSelector(query) || '{}'
if (template === 'ingress') next = applyLabelFilter(next, 'namespace', namespace.trim() || 'ingress-nginx')
@@ -54,18 +87,18 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
return <section className="space-y-2" aria-label="快捷查询模板">
{!compact && <h2 className="text-sm font-medium">快捷生成 LogQL</h2>}
{!compact && <p className="text-xs text-muted-foreground">选 app、namespace 或副本,再生成可编辑的 LogQL。</p>}
{!compact && <p className="text-xs text-muted-foreground">先选 namespace,再选 app,最后选副本 pod;app 候选只来自所选命名空间。</p>}
<div className="flex flex-wrap items-end gap-2">
<label className="min-w-36 flex-1 text-xs text-muted-foreground">app
<Input className="mt-1 h-8" list="quick-log-apps" value={app} onChange={event => setApp(event.target.value)} placeholder="搜索或输入 app" />
</label>
<datalist id="quick-log-apps">{apps.map(value => <option value={value} key={value} />)}</datalist>
<label className="min-w-36 flex-1 text-xs text-muted-foreground">namespace
<Input className="mt-1 h-8" list="quick-log-namespaces" value={namespace} onChange={event => setNamespace(event.target.value)} placeholder="可选" />
<Input className="mt-1 h-8" list="quick-log-namespaces" value={namespace} onChange={event => chooseNamespace(event.target.value)} placeholder="先选命名空间" />
</label>
<datalist id="quick-log-namespaces">{namespaces.map(value => <option value={value} key={value} />)}</datalist>
<label className="min-w-36 flex-1 text-xs text-muted-foreground">app
<Input className="mt-1 h-8" list="quick-log-apps" value={app} onChange={event => chooseApp(event.target.value)} placeholder={namespace ? '搜索该命名空间下的 app' : '先选 namespace 可缩小范围'} />
</label>
<datalist id="quick-log-apps">{apps.map(value => <option value={value} key={value} />)}</datalist>
<label className="min-w-36 flex-1 text-xs text-muted-foreground">副本 pod
<Input className="mt-1 h-8" list="quick-log-pods" value={pod} onChange={event => setPod(event.target.value)} placeholder="可选" />
<Input className="mt-1 h-8" list="quick-log-pods" value={pod} onChange={event => setPod(event.target.value)} placeholder={app ? '搜索该 app 的副本' : '可选'} />
</label>
<datalist id="quick-log-pods">{pods.map(value => <option value={value} key={value} />)}</datalist>
</div>
@@ -75,7 +108,7 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('warn')}>告警 warn</Button>
<Button size="sm" variant="outline" disabled={!pod.trim()} onClick={() => generate('pod')}>指定副本日志</Button>
<Button size="sm" variant="outline" onClick={() => generate('ingress')}>Ingress 访问日志</Button>
{!hasScope && <span className="self-center text-xs text-muted-foreground">先填写 app、namespace 或 pod,也可在 Label 浏览器选值</span>}
{!hasScope && <span className="self-center text-xs text-muted-foreground">先选 namespace,再选 app 或 pod,也可在 Label 浏览器选值</span>}
</div>
</section>
}
+18
View File
@@ -163,6 +163,20 @@ export default function LogsPage() {
<div className="space-y-6">
{error && <ErrorState message={error} onRetry={() => void reloadSources()} />}
{tab === 'query' && <section className="space-y-4">
{sources.length === 0 && (
<div className="rounded-xl border border-warning/40 bg-warning/10 p-4 text-sm">
<p className="font-medium text-warning">还没有可用的 Loki 数据源</p>
<p className="mt-1 text-muted-foreground">
请先到「数据源管理」关联 Loki:集群内可直接选「集群服务」(命名空间 / Service / 端口,无需 Ingress);
跨集群或公网查询,请先给 Loki 配置 Ingress 域名,再用「URL 直连」填写该域名。未关联数据源无法查询。
</p>
{admin && (
<Button size="sm" className="mt-2" variant="outline" onClick={() => setTab('sources')}>
去配置数据源
</Button>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<span className="flex h-10 items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm font-semibold text-info"><span className="size-2 rounded-full bg-info" />LogQL</span>
<div className="min-w-[260px] flex-1"><QueryInput key={sourceId} value={query} onChange={setQuery} onSubmit={() => void search()} sourceId={sourceId} history={queryHistory} /></div>
@@ -180,6 +194,10 @@ export default function LogsPage() {
{admin && tab === 'access' && <PolicyGroupsPage sources={sources} />}
{admin && tab === 'sources' && <section className="rounded-xl border bg-card p-4 space-y-4" aria-label="数据源管理">
<h2 className="font-semibold">Loki 数据源</h2>
<p className="text-xs text-muted-foreground">
提示:Loki 部署在集群内时,可直接添加「集群服务」数据源(无需 Ingress);跨集群 / 公网查询需先为 Loki 配置
Ingress 域名,再用「URL 直连」关联。未关联数据源时,查询页无法使用。
</p>
{/* 列表:平台部署自动注册的固定展示(🔒 不可编辑);手动添加的可编辑 */}
<div className="space-y-2">
@@ -14,6 +14,13 @@ const MODES = [
{ value: 'custom', label: '私有仓库', desc: '自有 registry 前缀(如华为云 SWR)' },
] as const
const COMPONENTS = [
{ key: 'loki', label: 'Loki', repo: 'grafana/loki', tagHint: '跟随部署版本' },
{ key: 'grafana', label: 'Grafana', repo: 'grafana/grafana', tagHint: '跟随部署版本' },
{ key: 'promtail', label: 'Promtail', repo: 'grafana/promtail', tagHint: '3.4.0' },
{ key: 'fluentbit', label: 'Fluent Bit', repo: 'fluent/fluent-bit', tagHint: '5.1.5' },
] as const
/**
* 镜像源设置 — 影响平台拉取的全部镜像(Loki/Grafana/Promtail/Fluent Bit),
* 参照 kuboard 的国内/海外选择;实时预览四类镜像的生效地址。
@@ -40,6 +47,7 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
const saved = await settingsApi.saveImageRegistry({
mode: cfg.mode,
customHost: cfg.mode === 'custom' ? customHost : undefined,
images: cfg.images,
})
setCfg(saved)
toast.success('镜像源已保存,后续部署/采集将使用新镜像地址')
@@ -91,6 +99,43 @@ export const ImageRegistrySection = memo(function ImageRegistrySection() {
</div>
)}
{/* 各服务镜像自定义 */}
<div className="space-y-2">
<Label>各服务镜像(可单独覆盖仓库 / Tag)</Label>
<div className="space-y-2">
{COMPONENTS.map(({ key, label, repo, tagHint }) => {
const img = cfg.images?.[key] || { repository: '', tag: '' }
const update = (patch: Partial<{ repository: string; tag: string }>) =>
setCfg({
...cfg,
images: { ...(cfg.images || {}), [key]: { ...img, ...patch } },
})
return (
<div key={key} className="grid items-center gap-2 sm:grid-cols-[110px_1fr_150px]">
<span className="text-sm font-medium">{label}</span>
<Input
aria-label={`${label} 镜像仓库`}
value={img.repository}
placeholder={repo}
onChange={(e) => update({ repository: e.target.value })}
className="font-mono text-xs"
/>
<Input
aria-label={`${label} 镜像 Tag`}
value={img.tag}
placeholder={tagHint}
onChange={(e) => update({ tag: e.target.value })}
className="font-mono text-xs"
/>
</div>
)
})}
</div>
<p className="text-xs text-muted-foreground">
仓库填镜像路径(如 <code>grafana/loki</code>);Tag 留空时 Loki / Grafana 跟随部署版本,Promtail / Fluent Bit 用内置默认。
</p>
</div>
{/* 生效镜像预览 */}
<div className="rounded-lg bg-muted/50 p-3 space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">生效镜像预览</p>
+6 -2
View File
@@ -68,12 +68,13 @@ const SettingsPage = memo(function SettingsPage() {
)
return (
<PageContainer title="系统设置" description="通用配置、告警通知与 LDAP 接入">
<PageContainer title="系统设置" description="通用配置、告警通知、LDAP 接入与默认镜像源">
<Tabs defaultValue="general" className="space-y-6">
<TabsList>
<TabsTrigger value="general">通用配置</TabsTrigger>
<TabsTrigger value="notify">告警通知</TabsTrigger>
<TabsTrigger value="ldap">LDAP 接入</TabsTrigger>
<TabsTrigger value="registry">默认镜像源</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-6">
@@ -175,13 +176,16 @@ const SettingsPage = memo(function SettingsPage() {
</TabsContent>
<TabsContent value="notify" className="space-y-6">
<ImageRegistrySection />
<NotifySection />
</TabsContent>
<TabsContent value="ldap">
<LdapSection />
</TabsContent>
<TabsContent value="registry" className="space-y-6">
<ImageRegistrySection />
</TabsContent>
</Tabs>
</PageContainer>
)
@@ -29,7 +29,7 @@ import { STORAGE_TYPE_ICONS, STORAGE_TYPE_ICON_CLASS, STORAGE_TYPES } from '../c
// 表单 schema:zod 为唯一校验源,消息中文且具体
const storageFormSchema = z.object({
name: z.string().min(1, '请输入存储配置名称'),
storageType: z.enum(['s3', 'oss', 'minio', 'rustfs']),
storageType: z.enum(['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs']),
endpoint: z.string().optional(),
bucket: z.string().min(1, '请填写 Bucket 名称'),
region: z.string().optional(),
@@ -82,7 +82,8 @@ export const StorageFormDialog = memo(function StorageFormDialog({
})
const storageType = form.watch('storageType')
// 区域仅对公有云(s3/oss)有意义;minio/rustfs 为本地部署不显示、不提交
const isCloudStorage = storageType === 's3' || storageType === 'oss'
const isCloudStorage =
storageType === 's3' || storageType === 'oss' || storageType === 'cos' || storageType === 'obs'
// 打开时重置;编辑模式回填(密钥留空=保留旧值)
useEffect(() => {
@@ -218,7 +219,11 @@ export const StorageFormDialog = memo(function StorageFormDialog({
? 's3.amazonaws.com'
: storageType === 'oss'
? 'oss-cn-hangzhou.aliyuncs.com'
: 'http://10.0.0.8:9000'
: storageType === 'cos'
? 'cos.ap-guangzhou.myqcloud.com'
: storageType === 'obs'
? 'obs.cn-north-4.myhuaweicloud.com'
: 'http://10.0.0.8:9000'
}
{...field}
/>
@@ -236,7 +241,12 @@ export const StorageFormDialog = memo(function StorageFormDialog({
<FormItem>
<FormLabel>Bucket *</FormLabel>
<FormControl>
<Input placeholder="loki-logs" {...field} />
<Input
placeholder={
storageType === 'cos' ? 'my-bucket-1250000000' : 'loki-logs'
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -250,7 +260,20 @@ export const StorageFormDialog = memo(function StorageFormDialog({
<FormItem>
<FormLabel>区域</FormLabel>
<FormControl>
<Input placeholder="us-east-1" {...field} />
<Input
placeholder={
storageType === 's3'
? 'us-east-1'
: storageType === 'oss'
? 'oss-cn-hangzhou'
: storageType === 'cos'
? 'ap-guangzhou'
: storageType === 'obs'
? 'cn-north-4'
: 'us-east-1'
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
+7 -1
View File
@@ -4,6 +4,8 @@ import { Cloud, Database, Package, type LucideIcon } from 'lucide-react'
export const STORAGE_TYPE_ICONS: Record<string, LucideIcon> = {
s3: Cloud,
oss: Cloud,
cos: Cloud,
obs: Cloud,
minio: Database,
rustfs: Package,
}
@@ -11,6 +13,8 @@ export const STORAGE_TYPE_ICONS: Record<string, LucideIcon> = {
export const STORAGE_TYPE_ICON_CLASS: Record<string, string> = {
s3: 'text-warning',
oss: 'text-destructive',
cos: 'text-info',
obs: 'text-info',
minio: 'text-info',
rustfs: 'text-info',
}
@@ -18,8 +22,10 @@ export const STORAGE_TYPE_ICON_CLASS: Record<string, string> = {
export const STORAGE_TYPE_COLORS: Record<string, string> = {
s3: 'bg-warning/10 text-warning',
oss: 'bg-destructive/10 text-destructive',
cos: 'bg-info/10 text-info',
obs: 'bg-info/10 text-info',
minio: 'bg-info/10 text-info',
rustfs: 'bg-info/10 text-info',
}
export const STORAGE_TYPES = ['s3', 'oss', 'minio', 'rustfs'] as const
export const STORAGE_TYPES = ['s3', 'oss', 'cos', 'obs', 'minio', 'rustfs'] as const
+1 -1
View File
@@ -94,7 +94,7 @@ export interface HostInfo {
export interface StorageConfig {
id: number;
name: string;
storageType: 's3' | 'oss' | 'minio' | 'rustfs';
storageType: 's3' | 'oss' | 'cos' | 'obs' | 'minio' | 'rustfs';
endpoint: string;
bucket: string;
region: string;
+79
View File
@@ -0,0 +1,79 @@
# opsx-loki 部署到 Kubernetes
## 文件
| 文件 | 说明 |
|---|---|
| `namespace.yaml` | `log` 命名空间(已有可跳过) |
| `secret.example.yaml` | JWT/ENCRYPTION 密钥模板(建议用 `kubectl create secret` 命令创建,不要提交真实值) |
| `pvc.yaml` | 平台数据卷(30Gi,默认 StorageClass) |
| `deployment.yaml` | 平台本体,**单副本 + Recreate** |
| `service.yaml` | ClusterIP `opsx-loki:8080` |
| `ingress.yaml` | 可选,按云厂商的 ingressClass 调整 |
## 部署步骤
```bash
# 1) 密钥(只生成一次,之后复用;丢失会导致已保存的凭证无法解密)
kubectl -n log create secret generic opsxloki-secrets \
--from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
--from-literal=ENCRYPTION_KEY="$(openssl rand -hex 32)"
# 2) 数据卷 + 平台
kubectl apply -f kubernetes/namespace.yaml
kubectl apply -f kubernetes/pvc.yaml
# 修改 deployment.yaml 里的 image 为实际仓库地址后:
kubectl apply -f kubernetes/deployment.yaml
kubectl apply -f kubernetes/service.yaml
# 3) 可选:Ingress(不需要就跳过,继续走外部 WAF 回源)
kubectl apply -f kubernetes/ingress.yaml
```
## 持久化(重要)
- `/app/data` 挂 PVC,保存 `opsxloki.json`、`kubeconfigs/`、`known_hosts`。
- **必须 `replicas: 1` + `strategy: Recreate`**:JSON DB 是单写者;滚动更新时两个 Pod 同时挂同一个 RWO PVC 会互相覆盖。
- DB 写入本身是原子的(临时文件 + fsync + rename),容器被杀不会写坏文件。
- 备份建议用 CSI VolumeSnapshot(AWS EBS / 腾讯 CBS / 华为 EVS / 阿里云 ESSD 都支持同一套 `snapshot.storage.k8s.io` API):
```yaml
apiVersion: snapshot.storage.k8s.io/v1
kind: VolumeSnapshot
metadata:
name: opsxloki-data-20261025
namespace: log
spec:
volumeSnapshotClassName: <集群的 snapshotClass>
source:
persistentVolumeClaimName: opsxloki-data
```
## 多云兼容
| 项 | 做法 |
|---|---|
| 存储类 | `pvc.yaml` 不写 `storageClassName`,用集群默认 SC;没有默认 SC 时填对应云的 SC 名(AWS `gp3` / 腾讯 `cbs` / 华为 `csi-disk` / 阿里云 `alicloud-disk-essd`) |
| 镜像仓库 | `deployment.yaml` 的 `image` 填目标云可拉的仓库(Harbor / ACR / TCR / SWR) |
| Loki 存储 | 平台已支持 S3 / OSS / COS / OBS,均为 S3 兼容,无需云厂商 SDK |
| 查询链路 | 平台与 Loki 同集群时,数据源选「集群服务」(namespace `log`、service `loki`、port `3100`),无需 Ingress/WAF |
## 与外部实例的关系
平台的 JSON DB 单写者,**不要两个实例共用同一份数据卷**。要么:
- 集群内新实例 + 迁移旧 `opsxloki.json`(停旧实例后拷贝);或
- 保留外部 229 实例做带外管理,集群内实例使用独立数据卷(两套数据)。
## 日志
- 平台**只写 stdout**(Go/GIN 默认),不写日志文件;`kubectl logs` / `docker logs` 可直接看,Pod 部署无需额外配置。
- Docker Compose:已配置 `json-file` 轮转 `max-size=50m`、`max-file=5`。
- Kubernetes:由 kubelet 统一轮转,节点级配置建议:
```yaml
# /var/lib/kubelet/config.yaml
containerLogMaxSize: 50Mi
containerLogMaxFiles: 5
```
也可以用 kubelet 启动参数 `--container-log-max-size=50Mi --container-log-max-files=5`。
- Loki / Promtail 同样输出到 stdout,由容器运行时轮转;不要在容器内写业务日志文件。
- 应用自身的部署日志写在 DB(`deployment_logs`),可在部署详情里查看,不占容器日志空间。
+87
View File
@@ -0,0 +1,87 @@
# opsx-loki 平台本体
#
# 关键约束:
# 1) replicas 必须为 1:JSON DB 为单写者,多副本会互相覆盖。
# 2) strategy 必须为 Recreate:RollingUpdate 期间会同时存在两个 Pod 挂同一个
# RWO PVC,两个进程同时写 opsxloki.json 会导致数据丢失。
# 3) /app/data 必须挂 PVC,否则重启后 DB / kubeconfig / known_hosts 全丢。
apiVersion: apps/v1
kind: Deployment
metadata:
name: opsx-loki
namespace: log
labels:
app: opsx-loki
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: opsx-loki
template:
metadata:
labels:
app: opsx-loki
spec:
securityContext:
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
containers:
- name: opsx-loki
image: lghost/opsxloki:latest
imagePullPolicy: Always
ports:
- name: http
containerPort: 8080
env:
- name: DATA_DIR
value: /app/data
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: opsxloki-secrets
key: JWT_SECRET
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: opsxloki-secrets
key: ENCRYPTION_KEY
# 可选:挂载本地集群 kubeconfig 时启用
# - name: KUBECONFIG
# value: /home/opsxloki/.kube/config
volumeMounts:
- name: data
mountPath: /app/data
# 可选:本地集群 kubeconfig(in-cluster 管理本集群时用)
# - name: kubeconfig
# mountPath: /home/opsxloki/.kube
# readOnly: true
readinessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /api/v1/health
port: http
initialDelaySeconds: 30
periodSeconds: 20
failureThreshold: 5
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumes:
- name: data
persistentVolumeClaim:
claimName: opsxloki-data
# - name: kubeconfig
# secret:
# secretName: opsxloki-kubeconfig
+24
View File
@@ -0,0 +1,24 @@
# 可选:通过集群 Ingress 暴露平台 UI。
# 阿里云 ACK 用 ALB IngressClass;腾讯 TKE 用 qcloud;华为 CCE 用 nginx/cce。
# 若继续走外部 WAF 回源到 Service,则不需要本文件。
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: opsx-loki
namespace: log
annotations:
# 阿里云 ALB 示例;其他云改成对应 ingressClass
alb.ingress.kubernetes.io/healthcheck-path: /api/v1/health
spec:
ingressClassName: alb
rules:
- host: opsx-loki.91ai.io
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: opsx-loki
port:
number: 8080
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: log
+15
View File
@@ -0,0 +1,15 @@
# 平台数据卷:opsxloki.json(JSON DB) + kubeconfigs + known_hosts。
# storageClassName 留空 = 使用集群默认 SC:
# AWS EBS(gp3) / 腾讯云 CBS / 华为云 EVS / 阿里云 ESSD 均可;
# 若集群没有默认 SC,在这里填对应云的 SC 名称。
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: opsxloki-data
namespace: log
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 30Gi
+14
View File
@@ -0,0 +1,14 @@
# 只做模板:不要提交真实密钥。
# 推荐直接用命令创建(避免密钥落盘到 Git):
# kubectl -n log create secret generic opsxloki-secrets \
# --from-literal=JWT_SECRET="$(openssl rand -hex 32)" \
# --from-literal=ENCRYPTION_KEY="$(openssl rand -hex 32)"
apiVersion: v1
kind: Secret
metadata:
name: opsxloki-secrets
namespace: log
type: Opaque
stringData:
JWT_SECRET: "REPLACE_WITH_32_BYTES_HEX"
ENCRYPTION_KEY: "REPLACE_WITH_32_BYTES_HEX"
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v1
kind: Service
metadata:
name: opsx-loki-nodeport
namespace: log
labels:
app: opsx-loki
spec:
type: NodePort
selector:
app: opsx-loki
ports:
- name: http
port: 8080
targetPort: http
nodePort: 30080
+15
View File
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
name: opsx-loki
namespace: log
labels:
app: opsx-loki
spec:
type: ClusterIP
selector:
app: opsx-loki
ports:
- name: http
port: 8080
targetPort: http