diff --git a/docs/development-guidelines.md b/docs/development-guidelines.md index b649d96f..16c1604d 100644 --- a/docs/development-guidelines.md +++ b/docs/development-guidelines.md @@ -188,9 +188,9 @@ Agent 必须满足: * 常规同步优先依据 heartbeat 返回的版本摘要判断 * 发现新版本时先备份旧文件 * 写入主配置、路由配置与必要证书文件 -* 先执行 `openresty -t` -* 成功后执行 `openresty -s reload` -* 失败时自动回滚并上报最终结果 +* 写入新配置后以运行态恢复为目标执行激活,Docker 模式优先重建容器并确认容器保持运行 +* 新配置激活失败时必须先尝试用目标配置恢复运行,再回滚到旧配置并重新拉起 OpenResty +* 回滚后 OpenResty 恢复正常时上报警告;回滚后仍无法恢复运行时上报失败 ## 6. 测试与交付要求 diff --git a/openflare_agent/internal/nginx/manager.go b/openflare_agent/internal/nginx/manager.go index 6c5ecb51..f5bce03d 100644 --- a/openflare_agent/internal/nginx/manager.go +++ b/openflare_agent/internal/nginx/manager.go @@ -138,6 +138,13 @@ func (e *DockerExecutor) Reload(ctx context.Context) error { } output, err = e.Runner.Run(ctx, e.DockerBinary, "exec", e.ContainerName, dockerRuntimeCommand, "-s", "reload") if err != nil { + if e.shouldRecreateAfterReloadFailure(string(output)) { + slog.Warn("docker openresty reload failed due to missing mounted files, recreating container", "container", e.ContainerName) + if recreateErr := e.EnsureRuntime(ctx, true); recreateErr != nil { + return fmt.Errorf("docker exec %s reload failed: %w: %s; recreate failed: %v", dockerRuntimeCommand, err, string(output), recreateErr) + } + return nil + } return fmt.Errorf("docker exec %s reload failed: %w: %s", dockerRuntimeCommand, err, string(output)) } return nil @@ -216,6 +223,9 @@ func (e *DockerExecutor) runContainer(ctx context.Context) error { if runErr != nil { return fmt.Errorf("docker run openresty failed: %w: %s", runErr, string(runOutput)) } + if err := e.CheckHealth(ctx); err != nil { + return err + } slog.Info("docker openresty container started", "container", e.ContainerName) return nil } @@ -236,6 +246,28 @@ func (e *DockerExecutor) validateMountSources() error { return nil } +func (e *DockerExecutor) shouldRecreateAfterReloadFailure(output string) bool { + text := strings.ToLower(strings.TrimSpace(output)) + if text == "" { + return false + } + if !strings.Contains(text, "no such file") && !strings.Contains(text, "cannot load certificate") { + return false + } + paths := []string{ + strings.ToLower(e.NginxCertDir), + strings.ToLower(e.NginxLuaDir), + strings.ToLower(DockerMainConfigPath), + strings.ToLower("/etc/nginx/conf.d"), + } + for _, path := range paths { + if strings.TrimSpace(path) != "" && strings.Contains(text, path) { + return true + } + } + return false +} + func (e *DockerExecutor) containerNotRunningError(ctx context.Context) error { inspectSummary := "" inspectOutput, inspectErr := e.Runner.Run(ctx, e.DockerBinary, "inspect", "-f", "status={{.State.Status}} exit_code={{.State.ExitCode}} error={{printf \"%q\" .State.Error}} oom_killed={{.State.OOMKilled}} finished_at={{.State.FinishedAt}}", e.ContainerName) @@ -326,43 +358,89 @@ type Manager struct { Executor Executor } -func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error { +type ApplyStatus string + +const ( + ApplyStatusSuccess ApplyStatus = "success" + ApplyStatusWarning ApplyStatus = "warning" + ApplyStatusFatal ApplyStatus = "fatal" +) + +type ApplyOutcome struct { + Status ApplyStatus + Message string +} + +func (m *Manager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) ApplyOutcome { slog.Info("openresty apply started", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath, "cert_files", len(supportFiles)) backup, err := m.backup() if err != nil { + return fatalApplyOutcome(fmt.Errorf("backup openresty config failed: %w", err)) + } + if err = m.writeTargetFiles(mainConfig, routeConfig, supportFiles); err != nil { + return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("write openresty config failed: %w", err)) + } + if err = m.activateConfig(ctx); err != nil { + return m.rollbackAfterFailedApply(ctx, backup, fmt.Errorf("activate openresty runtime failed: %w", err)) + } + slog.Info("openresty apply completed successfully", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath) + return ApplyOutcome{Status: ApplyStatusSuccess} +} + +func (m *Manager) writeTargetFiles(mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error { + if err := m.EnsureLuaAssets(); err != nil { return err } - if err = m.EnsureLuaAssets(); err != nil { - slog.Error("writing lua assets failed, restoring backup", "error", err) - _ = m.restore(backup) - return err - } - if err = m.writeCertFiles(supportFiles); err != nil { - slog.Error("writing cert files failed, restoring backup", "error", err) - _ = m.restore(backup) + if err := m.writeCertFiles(supportFiles); err != nil { return err } renderedMainConfig := m.renderMainConfig(mainConfig) - if err = os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), 0o644); err != nil { - slog.Error("writing openresty main config failed, restoring backup", "error", err) - _ = m.restore(backup) + if err := os.WriteFile(m.MainConfigPath, []byte(renderedMainConfig), 0o644); err != nil { return err } renderedRouteConfig := m.renderRouteConfig(routeConfig) - if err = os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), 0o644); err != nil { - slog.Error("writing openresty route config failed, restoring backup", "error", err) - _ = m.restore(backup) + if err := os.WriteFile(m.RouteConfigPath, []byte(renderedRouteConfig), 0o644); err != nil { return err } - if err = m.Executor.Reload(ctx); err != nil { - slog.Error("openresty reload failed after config write, restoring backup", "error", err) - _ = m.restore(backup) - return err - } - slog.Info("openresty apply completed successfully", "main_config", m.MainConfigPath, "route_config", m.RouteConfigPath) return nil } +func (m *Manager) activateConfig(ctx context.Context) error { + if m.Executor == nil { + return errors.New("executor 未配置") + } + if _, ok := m.Executor.(*DockerExecutor); ok { + return m.Executor.EnsureRuntime(ctx, true) + } + return m.Executor.Reload(ctx) +} + +func (m *Manager) rollbackAfterFailedApply(ctx context.Context, backup *backupState, applyErr error) ApplyOutcome { + slog.Warn("openresty apply failed, restoring previous config", "error", applyErr) + if err := m.restore(backup); err != nil { + return fatalApplyOutcome(fmt.Errorf("restore openresty backup failed after apply error %v: %w", applyErr, err)) + } + if err := m.activateConfig(ctx); err != nil { + return fatalApplyOutcome(fmt.Errorf("apply failed: %v; rollback recovery failed: %w", applyErr, err)) + } + message := fmt.Sprintf("apply failed, rolled back to previous config: %v", applyErr) + slog.Warn("openresty apply rolled back successfully", "message", message) + return ApplyOutcome{ + Status: ApplyStatusWarning, + Message: message, + } +} + +func fatalApplyOutcome(err error) ApplyOutcome { + if err == nil { + return ApplyOutcome{Status: ApplyStatusFatal} + } + return ApplyOutcome{ + Status: ApplyStatusFatal, + Message: strings.TrimSpace(err.Error()), + } +} + func (m *Manager) EnsureLuaAssets() error { if strings.TrimSpace(m.LuaDir) == "" { return nil diff --git a/openflare_agent/internal/nginx/manager_test.go b/openflare_agent/internal/nginx/manager_test.go index 96950a6e..d9e13f31 100644 --- a/openflare_agent/internal/nginx/manager_test.go +++ b/openflare_agent/internal/nginx/manager_test.go @@ -28,6 +28,11 @@ type fakeExecutor struct { reloadErr error } +type scriptedExecutor struct { + reloadErrors []error + reloadCalls int +} + func (r *fakeRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { r.calls = append(r.calls, runCall{name: name, args: append([]string{}, args...)}) if r.runFn != nil { @@ -56,6 +61,31 @@ func (e *fakeExecutor) Restart(ctx context.Context) error { return e.reloadErr } +func (e *scriptedExecutor) Test(ctx context.Context) error { + return nil +} + +func (e *scriptedExecutor) Reload(ctx context.Context) error { + index := e.reloadCalls + e.reloadCalls++ + if index >= len(e.reloadErrors) { + return nil + } + return e.reloadErrors[index] +} + +func (e *scriptedExecutor) EnsureRuntime(ctx context.Context, recreate bool) error { + return nil +} + +func (e *scriptedExecutor) CheckHealth(ctx context.Context) error { + return nil +} + +func (e *scriptedExecutor) Restart(ctx context.Context) error { + return nil +} + func TestPathExecutorCommands(t *testing.T) { runner := &fakeRunner{} executor := &PathExecutor{ @@ -209,10 +239,15 @@ func TestDockerExecutorStartsContainerWhenMissing(t *testing.T) { func TestDockerExecutorStartsStoppedContainer(t *testing.T) { mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t) + inspectCalls := 0 runner := &fakeRunner{ runFn: func(name string, args ...string) ([]byte, error) { if len(args) >= 2 && args[0] == "inspect" { - return []byte("false"), nil + inspectCalls++ + if inspectCalls < 3 { + return []byte("false"), nil + } + return []byte("true"), nil } return []byte("ok"), nil }, @@ -234,8 +269,8 @@ func TestDockerExecutorStartsStoppedContainer(t *testing.T) { t.Fatalf("Reload failed: %v", err) } - if len(runner.calls) != 4 { - t.Fatalf("expected 4 calls, got %d", len(runner.calls)) + if len(runner.calls) != 5 { + t.Fatalf("expected 5 calls, got %d", len(runner.calls)) } if runner.calls[0].args[0] != "inspect" { t.Fatalf("expected docker inspect on first call, got %#v", runner.calls[0]) @@ -249,6 +284,9 @@ func TestDockerExecutorStartsStoppedContainer(t *testing.T) { if runner.calls[3].args[0] != "run" { t.Fatalf("expected docker run on fourth call, got %#v", runner.calls[3]) } + if runner.calls[4].args[0] != "inspect" { + t.Fatalf("expected docker inspect after run, got %#v", runner.calls[4]) + } } func TestDockerExecutorReloadsRunningContainerInPlace(t *testing.T) { @@ -287,9 +325,55 @@ func TestDockerExecutorReloadsRunningContainerInPlace(t *testing.T) { } } +func TestDockerExecutorReloadRecreatesContainerWhenMountedCertMissing(t *testing.T) { + mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t) + runner := &fakeRunner{ + runFn: func(name string, args ...string) ([]byte, error) { + if len(args) >= 1 && args[0] == "inspect" { + return []byte("true"), nil + } + if len(args) >= 2 && args[0] == "exec" { + return []byte(`nginx: [emerg] cannot load certificate "/etc/nginx/openflare-certs/1.crt": BIO_new_file() failed (SSL: error:80000002:system library::No such file or directory)`), errors.New("exit status 1") + } + return []byte("ok"), nil + }, + } + executor := &DockerExecutor{ + DockerBinary: "docker", + ContainerName: "openflare-openresty", + Image: "openresty/openresty:alpine", + MainConfigPath: mainConfigPath, + RouteConfigDir: routeConfigDir, + CertDir: certDir, + NginxCertDir: "/etc/nginx/openflare-certs", + LuaDir: luaDir, + NginxLuaDir: "/etc/nginx/openflare-lua", + OpenrestyObservabilityPort: 18081, + Runner: runner, + } + + if err := executor.Reload(context.Background()); err != nil { + t.Fatalf("Reload failed: %v", err) + } + + if len(runner.calls) != 6 { + t.Fatalf("expected 6 calls, got %d", len(runner.calls)) + } + if runner.calls[2].args[0] != "inspect" || runner.calls[3].args[0] != "rm" || runner.calls[4].args[0] != "run" || runner.calls[5].args[0] != "inspect" { + t.Fatalf("expected recreate after reload failure, got %#v", runner.calls) + } +} + func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) { mainConfigPath, routeConfigDir, certDir, luaDir := prepareDockerMountSources(t) - runner := &fakeRunner{} + runner := &fakeRunner{ + runFn: func(name string, args ...string) ([]byte, error) { + if len(args) >= 1 && args[0] == "inspect" { + return []byte("true"), nil + } + return []byte("ok"), nil + }, + } executor := &DockerExecutor{ DockerBinary: "docker", ContainerName: "openflare-openresty", @@ -308,8 +392,8 @@ func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) { t.Fatalf("runContainer failed: %v", err) } - if len(runner.calls) != 1 { - t.Fatalf("expected one docker run call, got %d", len(runner.calls)) + if len(runner.calls) != 2 { + t.Fatalf("expected docker run plus health check, got %d calls", len(runner.calls)) } expectedArgs := []string{ @@ -327,6 +411,9 @@ func TestDockerExecutorRunContainerMountsManagedFiles(t *testing.T) { if !reflect.DeepEqual(runner.calls[0].args, expectedArgs) { t.Fatalf("unexpected docker run args: %#v", runner.calls[0].args) } + if !reflect.DeepEqual(runner.calls[1].args, []string{"inspect", "-f", "{{.State.Running}}", "openflare-openresty"}) { + t.Fatalf("unexpected docker health check args: %#v", runner.calls[1].args) + } } func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) { @@ -356,8 +443,8 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) { if err := executor.EnsureRuntime(context.Background(), true); err != nil { t.Fatalf("EnsureRuntime failed: %v", err) } - if len(runner.calls) != 3 { - t.Fatalf("expected 3 calls, got %d", len(runner.calls)) + if len(runner.calls) != 4 { + t.Fatalf("expected 4 calls, got %d", len(runner.calls)) } if runner.calls[1].args[0] != "rm" { t.Fatalf("expected docker rm on second call, got %#v", runner.calls[1]) @@ -365,6 +452,9 @@ func TestDockerExecutorRecreatesContainerOnStartup(t *testing.T) { if runner.calls[2].args[0] != "run" { t.Fatalf("expected docker run on third call, got %#v", runner.calls[2]) } + if runner.calls[3].args[0] != "inspect" { + t.Fatalf("expected docker inspect after run, got %#v", runner.calls[3]) + } } func TestDockerExecutorRunContainerRejectsMissingMainConfigFile(t *testing.T) { @@ -497,21 +587,21 @@ func TestManagerApplyAndChecksumIncludeMainConfig(t *testing.T) { Executor: &fakeExecutor{}, } - err := manager.Apply( + outcome := manager.Apply( context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;\n", []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, ) - if err != nil { - t.Fatalf("Apply failed: %v", err) + if outcome.Status != ApplyStatusSuccess { + t.Fatalf("Apply failed: %#v", outcome) } mainData, err := os.ReadFile(mainPath) if err != nil { t.Fatalf("failed to read main config: %v", err) } - expectedMain := "include " + routePath + ";\naccess_log " + filepath.Join(filepath.Dir(routePath), "openflare_access.log") + " openflare_json;\n" + expectedMain := "include " + routePath + ";\naccess_log " + filepath.ToSlash(filepath.Join(filepath.Dir(routePath), "openflare_access.log")) + " openflare_json;\n" if string(mainData) != expectedMain { t.Fatalf("unexpected main config: %s", string(mainData)) } @@ -553,8 +643,8 @@ func TestManagerApplyUsesRuntimeRouteConfigPath(t *testing.T) { Executor: &fakeExecutor{}, } - if err := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", "server { listen 80; }\n", nil); err != nil { - t.Fatalf("Apply failed: %v", err) + if outcome := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\naccess_log __OPENFLARE_ACCESS_LOG__ openflare_json;\n", "server { listen 80; }\n", nil); outcome.Status != ApplyStatusSuccess { + t.Fatalf("Apply failed: %#v", outcome) } mainData, err := os.ReadFile(mainPath) @@ -631,12 +721,12 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) { Executor: &fakeExecutor{}, } - err := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\n__OPENFLARE_RESOLVER_DIRECTIVE__server { listen __OPENFLARE_OBSERVABILITY_LISTEN__; }", "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;", []protocol.SupportFile{ + outcome := manager.Apply(context.Background(), "include __OPENFLARE_ROUTE_CONFIG__;\n__OPENFLARE_RESOLVER_DIRECTIVE__server { listen __OPENFLARE_OBSERVABILITY_LISTEN__; }", "ssl_certificate __OPENFLARE_CERT_DIR__/1.crt;", []protocol.SupportFile{ {Path: "1.crt", Content: "cert-data"}, {Path: "1.key", Content: "key-data"}, }) - if err != nil { - t.Fatalf("Apply failed: %v", err) + if outcome.Status != ApplyStatusSuccess { + t.Fatalf("Apply failed: %#v", outcome) } routeData, err := os.ReadFile(manager.RouteConfigPath) @@ -667,7 +757,7 @@ func TestManagerApplyWritesSupportFilesAndReplacesPlaceholder(t *testing.T) { if err != nil { t.Fatalf("expected managed lua file to exist, stat err = %v", err) } - if luaInfo.Mode().Perm() != 0o644 { + if runtime.GOOS != "windows" && luaInfo.Mode().Perm() != 0o644 { t.Fatalf("unexpected lua mode: %o", luaInfo.Mode().Perm()) } } @@ -802,11 +892,11 @@ func TestManagerRollbackRestoresCertFiles(t *testing.T) { }, } - err := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{ + outcome := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{ {Path: "1.crt", Content: "new-cert"}, }) - if err == nil { - t.Fatal("expected Apply to fail") + if outcome.Status != ApplyStatusFatal { + t.Fatalf("expected fatal apply outcome, got %#v", outcome) } mainData, err := os.ReadFile(mainPath) @@ -832,6 +922,51 @@ func TestManagerRollbackRestoresCertFiles(t *testing.T) { } } +func TestManagerApplyReturnsWarningWhenRollbackRecoversRuntime(t *testing.T) { + tempDir := t.TempDir() + routePath := filepath.Join(tempDir, "routes.conf") + mainPath := filepath.Join(tempDir, "nginx.conf") + certDir := filepath.Join(tempDir, "certs") + if err := os.MkdirAll(certDir, 0o755); err != nil { + t.Fatalf("MkdirAll failed: %v", err) + } + if err := os.WriteFile(mainPath, []byte("old-main"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + if err := os.WriteFile(routePath, []byte("old-route"), 0o644); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + if err := os.WriteFile(filepath.Join(certDir, "1.crt"), []byte("old-cert"), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + manager := &Manager{ + MainConfigPath: mainPath, + RouteConfigPath: routePath, + CertDir: certDir, + NginxCertDir: "/etc/nginx/openflare-certs", + LuaDir: filepath.Join(tempDir, "lua"), + NginxLuaDir: "/etc/nginx/openflare-lua", + Executor: &scriptedExecutor{ + reloadErrors: []error{errors.New("target config failed"), nil}, + }, + } + + outcome := manager.Apply(context.Background(), "new-main", "new-route", []protocol.SupportFile{ + {Path: "1.crt", Content: "new-cert"}, + }) + if outcome.Status != ApplyStatusWarning { + t.Fatalf("expected warning apply outcome, got %#v", outcome) + } + + mainData, err := os.ReadFile(mainPath) + if err != nil { + t.Fatalf("failed to read main config: %v", err) + } + if string(mainData) != "old-main" { + t.Fatalf("expected main rollback, got %s", string(mainData)) + } +} + func TestManagerCertFileTargetPathRejectsEscapes(t *testing.T) { manager := &Manager{CertDir: filepath.Join(t.TempDir(), "certs")} if err := os.MkdirAll(manager.CertDir, 0o755); err != nil { @@ -883,11 +1018,11 @@ func TestManagerApplyRejectsCertFilePathTraversal(t *testing.T) { Executor: &fakeExecutor{}, } - err := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{ + outcome := manager.Apply(context.Background(), "main", "route", []protocol.SupportFile{ {Path: "../escape.crt", Content: "bad"}, }) - if err == nil { - t.Fatal("expected Apply to reject traversal path") + if outcome.Status != ApplyStatusWarning { + t.Fatalf("expected warning apply outcome, got %#v", outcome) } if _, statErr := os.Stat(filepath.Join(tempDir, "escape.crt")); !os.IsNotExist(statErr) { diff --git a/openflare_agent/internal/sync/service.go b/openflare_agent/internal/sync/service.go index 39f6cc1f..f8b344ee 100644 --- a/openflare_agent/internal/sync/service.go +++ b/openflare_agent/internal/sync/service.go @@ -4,15 +4,18 @@ import ( "context" "crypto/sha256" "encoding/hex" + "fmt" "log/slog" "strings" + "openflare-agent/internal/nginx" "openflare-agent/internal/protocol" "openflare-agent/internal/state" ) const ( ApplyResultSuccess = "success" + ApplyResultWarning = "warning" ApplyResultFailed = "failed" ) @@ -22,7 +25,7 @@ type ConfigClient interface { } type NginxManager interface { - Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error + Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) nginx.ApplyOutcome EnsureRuntime(ctx context.Context, recreate bool) error CurrentChecksum() (string, error) } @@ -154,55 +157,79 @@ func (s *Service) applyIfNeeded(ctx context.Context, mode string, startup bool, mainConfigChecksum := checksumString(config.MainConfig) routeConfigChecksum := checksumString(routeConfig) slog.Info("applying new openresty config", "mode", mode, "from_version", snapshot.CurrentVersion, "to_version", config.Version, "old_checksum", currentChecksum, "new_checksum", config.Checksum) - if err := s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles); err != nil { - slog.Error("apply openresty config failed", "mode", mode, "version", config.Version, "error", err) - snapshot.LastError = err.Error() - snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy - snapshot.OpenrestyMessage = err.Error() - _ = s.stateStore.Save(snapshot) - reportErr := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{ - NodeID: snapshot.NodeID, - Version: config.Version, - Result: ApplyResultFailed, - Message: err.Error(), - Checksum: config.Checksum, - MainConfigChecksum: mainConfigChecksum, - RouteConfigChecksum: routeConfigChecksum, - SupportFileCount: len(config.SupportFiles), - }) - if reportErr != nil { - slog.Error("report failed apply log failed", "version", config.Version, "error", reportErr) - return reportErr + outcome := s.nginxManager.Apply(ctx, config.MainConfig, routeConfig, config.SupportFiles) + message := strings.TrimSpace(outcome.Message) + if outcome.Status == "" { + outcome.Status = nginx.ApplyStatusFatal + if message == "" { + message = "openresty apply returned empty outcome" } - slog.Warn("failed apply log reported", "version", config.Version) - return err } - slog.Info("openresty config applied successfully", "mode", mode, "version", config.Version) - snapshot.CurrentVersion = config.Version - snapshot.CurrentChecksum = config.Checksum - snapshot.LastError = "" - snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy - snapshot.OpenrestyMessage = "" + + reportResult := ApplyResultFailed + switch outcome.Status { + case nginx.ApplyStatusSuccess: + slog.Info("openresty config applied successfully", "mode", mode, "version", config.Version) + snapshot.CurrentVersion = config.Version + snapshot.CurrentChecksum = config.Checksum + snapshot.LastError = "" + snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy + snapshot.OpenrestyMessage = "" + reportResult = ApplyResultSuccess + if message == "" { + message = "apply success" + } + case nginx.ApplyStatusWarning: + if message == "" { + message = "apply rolled back to previous config" + } + slog.Warn("openresty config apply rolled back", "mode", mode, "version", config.Version, "message", message) + snapshot.LastError = message + snapshot.OpenrestyStatus = protocol.OpenrestyStatusHealthy + snapshot.OpenrestyMessage = message + reportResult = ApplyResultWarning + default: + if message == "" { + message = "openresty apply failed" + } + slog.Error("apply openresty config failed", "mode", mode, "version", config.Version, "message", message) + snapshot.LastError = message + snapshot.OpenrestyStatus = protocol.OpenrestyStatusUnhealthy + snapshot.OpenrestyMessage = message + } + if err := s.stateStore.Save(snapshot); err != nil { return err } if err := s.client.ReportApplyLog(ctx, protocol.ApplyLogPayload{ NodeID: snapshot.NodeID, Version: config.Version, - Result: ApplyResultSuccess, - Message: "apply success", + Result: reportResult, + Message: message, Checksum: config.Checksum, MainConfigChecksum: mainConfigChecksum, RouteConfigChecksum: routeConfigChecksum, SupportFileCount: len(config.SupportFiles), }); err != nil { - slog.Error("report successful apply log failed", "version", config.Version, "error", err) + slog.Error("report apply log failed", "version", config.Version, "result", reportResult, "error", err) return err } - slog.Debug("successful apply log reported", "version", config.Version) + if reportResult == ApplyResultFailed { + slog.Warn("failed apply log reported", "version", config.Version) + return outcomeError(config.Version, message) + } + slog.Debug("apply log reported", "version", config.Version, "result", reportResult) return nil } +func outcomeError(version string, message string) error { + trimmed := strings.TrimSpace(message) + if trimmed == "" { + trimmed = "openresty apply failed" + } + return fmt.Errorf("apply version %s failed: %s", version, trimmed) +} + func checksumString(content string) string { sum := sha256.Sum256([]byte(content)) return hex.EncodeToString(sum[:]) diff --git a/openflare_agent/internal/sync/service_test.go b/openflare_agent/internal/sync/service_test.go index 9bfde5a6..11f28083 100644 --- a/openflare_agent/internal/sync/service_test.go +++ b/openflare_agent/internal/sync/service_test.go @@ -24,7 +24,7 @@ type fakeClient struct { } type fakeManager struct { - applyErr error + applyOutcome nginx.ApplyOutcome currentChecksum string currentChecksumErr error ensureErr error @@ -64,11 +64,14 @@ func (f *fakeClient) ReportApplyLog(ctx context.Context, payload protocol.ApplyL return nil } -func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) error { +func (m *fakeManager) Apply(ctx context.Context, mainConfig string, routeConfig string, supportFiles []protocol.SupportFile) nginx.ApplyOutcome { m.applyMainContents = append(m.applyMainContents, mainConfig) m.applyRouteContents = append(m.applyRouteContents, routeConfig) m.applyFiles = append(m.applyFiles, append([]protocol.SupportFile(nil), supportFiles...)) - return m.applyErr + if m.applyOutcome.Status == "" { + return nginx.ApplyOutcome{Status: nginx.ApplyStatusSuccess} + } + return m.applyOutcome } func (m *fakeManager) EnsureRuntime(ctx context.Context, recreate bool) error { @@ -166,17 +169,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { }, } - tempDir := t.TempDir() - mainPath := filepath.Join(tempDir, "nginx.conf") - routePath := filepath.Join(tempDir, "routes.conf") - if err := os.WriteFile(mainPath, []byte("worker_processes auto;"), 0o644); err != nil { - t.Fatalf("failed to seed main file: %v", err) - } - if err := os.WriteFile(routePath, []byte("server { listen 80; }"), 0o644); err != nil { - t.Fatalf("failed to seed route file: %v", err) - } - - stateStore := state.NewStore(filepath.Join(tempDir, "state.json")) + stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) nodeID, err := stateStore.EnsureNodeID() if err != nil { t.Fatalf("EnsureNodeID failed: %v", err) @@ -189,11 +182,10 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { t.Fatalf("failed to seed state: %v", err) } - service := New(client, &nginx.Manager{ - MainConfigPath: mainPath, - RouteConfigPath: routePath, - Executor: &fakeExecutor{ - testErr: context.DeadlineExceeded, + service := New(client, &fakeManager{ + applyOutcome: nginx.ApplyOutcome{ + Status: nginx.ApplyStatusFatal, + Message: "openresty failed after rollback", }, }, stateStore) @@ -202,22 +194,7 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { Checksum: client.config.Checksum, }) if err == nil { - t.Fatal("expected SyncOnce to fail when nginx test fails") - } - - data, readErr := os.ReadFile(routePath) - if readErr != nil { - t.Fatalf("failed to read route file after rollback: %v", readErr) - } - if string(data) != "server { listen 80; }" { - t.Fatal("expected original route config to be restored after rollback") - } - mainData, readErr := os.ReadFile(mainPath) - if readErr != nil { - t.Fatalf("failed to read main file after rollback: %v", readErr) - } - if string(mainData) != "worker_processes auto;" { - t.Fatal("expected original main config to be restored after rollback") + t.Fatal("expected SyncOnce to fail when apply outcome is fatal") } snapshot, loadErr := stateStore.Load() if loadErr != nil { @@ -226,6 +203,9 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { if snapshot.CurrentVersion != "20260309-001" { t.Fatal("expected failed sync not to overwrite current version") } + if snapshot.OpenrestyStatus != protocol.OpenrestyStatusUnhealthy { + t.Fatalf("expected unhealthy openresty status, got %q", snapshot.OpenrestyStatus) + } if len(client.reports) != 1 || client.reports[0].Result != ApplyResultFailed { t.Fatal("expected failed apply report to be sent") } @@ -240,6 +220,64 @@ func TestSyncOnceRollbackOnNginxFailure(t *testing.T) { } } +func TestSyncOnceReportsWarningWhenRollbackKeepsOpenrestyHealthy(t *testing.T) { + client := &fakeClient{ + config: protocol.ActiveConfigResponse{ + Version: "20260309-002", + Checksum: "checksum-2", + MainConfig: "worker_processes 2;", + RouteConfig: "server { listen 81; }", + RenderedConfig: "server { listen 81; }", + SupportFiles: []protocol.SupportFile{{Path: "1.crt", Content: "cert"}}, + CreatedAt: time.Now().Format(time.RFC3339), + }, + } + + stateStore := state.NewStore(filepath.Join(t.TempDir(), "state.json")) + nodeID, err := stateStore.EnsureNodeID() + if err != nil { + t.Fatalf("EnsureNodeID failed: %v", err) + } + if err = stateStore.Save(&state.Snapshot{ + NodeID: nodeID, + CurrentVersion: "20260309-001", + CurrentChecksum: "checksum-1", + }); err != nil { + t.Fatalf("failed to seed state: %v", err) + } + + service := New(client, &fakeManager{ + applyOutcome: nginx.ApplyOutcome{ + Status: nginx.ApplyStatusWarning, + Message: "apply failed, rolled back to previous config", + }, + }, stateStore) + + if err = service.SyncOnce(context.Background(), &protocol.ActiveConfigMeta{ + Version: client.config.Version, + Checksum: client.config.Checksum, + }); err != nil { + t.Fatalf("expected warning outcome to keep sync successful, got %v", err) + } + + snapshot, err := stateStore.Load() + if err != nil { + t.Fatalf("failed to load state: %v", err) + } + if snapshot.CurrentVersion != "20260309-001" || snapshot.CurrentChecksum != "checksum-1" { + t.Fatal("expected warning apply to keep previous version state") + } + if snapshot.OpenrestyStatus != protocol.OpenrestyStatusHealthy { + t.Fatalf("expected healthy openresty after rollback, got %q", snapshot.OpenrestyStatus) + } + if snapshot.LastError == "" { + t.Fatal("expected rollback warning to be recorded") + } + if len(client.reports) != 1 || client.reports[0].Result != ApplyResultWarning { + t.Fatal("expected warning apply report to be sent") + } +} + func TestSyncOnStartupRecreatesRuntimeWhenChecksumMatches(t *testing.T) { client := &fakeClient{ config: protocol.ActiveConfigResponse{ diff --git a/openflare_server/service/agent.go b/openflare_server/service/agent.go index 8b781088..85edc4f8 100644 --- a/openflare_server/service/agent.go +++ b/openflare_server/service/agent.go @@ -17,6 +17,7 @@ const ( NodeStatusOffline = "offline" NodeStatusPending = "pending" ApplyResultOK = "success" + ApplyResultWarning = "warning" ApplyResultFailed = "failed" OpenrestyStatusHealthy = "healthy" OpenrestyStatusUnhealthy = "unhealthy" @@ -234,8 +235,8 @@ func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) { if payload.Version == "" { return nil, errors.New("version 不能为空") } - if payload.Result != ApplyResultOK && payload.Result != ApplyResultFailed { - return nil, errors.New("result 仅支持 success 或 failed") + if payload.Result != ApplyResultOK && payload.Result != ApplyResultWarning && payload.Result != ApplyResultFailed { + return nil, errors.New("result 仅支持 success、warning 或 failed") } slog.Debug("agent apply log received", "node_id", payload.NodeID, "version", payload.Version, "result", payload.Result) @@ -273,6 +274,8 @@ func ReportApplyLog(payload ApplyLogPayload) (*model.ApplyLog, error) { } if payload.Result == ApplyResultOK { slog.Debug("agent apply reported success", "node_id", payload.NodeID, "version", payload.Version) + } else if payload.Result == ApplyResultWarning { + slog.Warn("agent apply reported warning", "node_id", payload.NodeID, "version", payload.Version, "message", payload.Message) } else { slog.Error("agent apply reported failure", "node_id", payload.NodeID, "version", payload.Version, "message", payload.Message) } diff --git a/openflare_server/web/features/apply-logs/components/apply-logs-page.tsx b/openflare_server/web/features/apply-logs/components/apply-logs-page.tsx index 59ae7a80..e1f6f170 100644 --- a/openflare_server/web/features/apply-logs/components/apply-logs-page.tsx +++ b/openflare_server/web/features/apply-logs/components/apply-logs-page.tsx @@ -1,345 +1,349 @@ -'use client'; - -import Link from 'next/link'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useMemo, useState } from 'react'; - -import { EmptyState } from '@/components/feedback/empty-state'; -import { ErrorState } from '@/components/feedback/error-state'; -import { InlineMessage } from '@/components/feedback/inline-message'; -import { LoadingState } from '@/components/feedback/loading-state'; -import { PageHeader } from '@/components/layout/page-header'; -import { AppCard } from '@/components/ui/app-card'; -import { StatusBadge } from '@/components/ui/status-badge'; -import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; -import type { ApplyLogItem } from '@/features/apply-logs/types'; -import { - PrimaryButton, - ResourceInput, - SecondaryButton, -} from '@/features/shared/components/resource-primitives'; -import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; - -const applyLogsQueryKey = (nodeId: string) => ['apply-logs', nodeId] as const; - -function getErrorMessage(error: unknown) { - return error instanceof Error ? error.message : '请求失败,请稍后重试。'; -} - -function getResultMeta(result: string) { - if (result === 'success') { - return { label: '成功', variant: 'success' as const }; - } - - return { label: '失败', variant: 'danger' as const }; -} - -function buildSummary(logs: ApplyLogItem[]) { - const nodeIds = new Set(logs.map((item) => item.node_id)); - - return [ - { label: '记录总数', value: logs.length }, - { - label: '成功', - value: logs.filter((item) => item.result === 'success').length, - }, - { - label: '失败', - value: logs.filter((item) => item.result !== 'success').length, - }, - { label: '节点数', value: nodeIds.size }, - ]; -} - -function truncateHash(value: string) { - if (!value) { - return '—'; - } - - return value.length > 12 ? `${value.slice(0, 12)}...` : value; -} - -export function ApplyLogsPage() { - const queryClient = useQueryClient(); - const [nodeFilterInput, setNodeFilterInput] = useState(''); - const [nodeFilter, setNodeFilter] = useState(''); - const [selectedLogId, setSelectedLogId] = useState(null); - const [feedback, setFeedback] = useState(null); - - const logsQuery = useQuery({ - queryKey: applyLogsQueryKey(nodeFilter), - queryFn: () => getApplyLogs(nodeFilter), - }); - - const logs = useMemo(() => logsQuery.data ?? [], [logsQuery.data]); - const summary = useMemo(() => buildSummary(logs), [logs]); - - useEffect(() => { - if (logs.length === 0) { - setSelectedLogId(null); - return; - } - - if (!logs.some((item) => item.id === selectedLogId)) { - setSelectedLogId(logs[0].id); - } - }, [logs, selectedLogId]); - - const selectedLog = logs.find((item) => item.id === selectedLogId) ?? null; - - const handleSearch = () => { - setFeedback(null); - setNodeFilter(nodeFilterInput.trim()); - }; - - const handleReset = () => { - setFeedback(null); - setNodeFilter(''); - setNodeFilterInput(''); - }; - - return ( -
- - 返回节点页 - - } - /> - - {feedback ? : null} - - -
- {summary.map((item) => ( -
-

- {item.label} -

-

- {item.value} -

-
- ))} -
-
- -
- - void queryClient.invalidateQueries({ - queryKey: applyLogsQueryKey(nodeFilter), - }) - } - > - 刷新 - - } - > -
-
- setNodeFilterInput(event.target.value)} - placeholder="输入 node_id 过滤应用记录" - className="lg:max-w-md" - /> -
- - 筛选 - - - 清空 - -
-
- - {logsQuery.isLoading ? ( - - ) : logsQuery.isError ? ( - - ) : logs.length === 0 ? ( - - ) : ( -
- - - - - - - - - - - - - {logs.map((log) => { - const resultMeta = getResultMeta(log.result); - return ( - - - - - - - - - ); - })} - -
Node ID版本结果Checksum时间详情
- {log.node_id} - - {log.version} - - - - {truncateHash(log.checksum)} - -
-

{formatDateTime(log.created_at)}

-

- {formatRelativeTime(log.created_at)} -

-
-
- { - setSelectedLogId(log.id); - setFeedback( - `已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`, - ); - }} - className="px-3 py-2 text-xs" - > - 查看详情 - -
-
- )} -
-
- - - {selectedLog ? ( -
-
- - - -
- -
-
-

- 创建时间 -

-

- {formatDateTime(selectedLog.created_at)} -

-
-
-

- 相对时间 -

-

- {formatRelativeTime(selectedLog.created_at)} -

-
-
-

- 目标 Checksum -

-

- {selectedLog.checksum || '无'} -

-
-
-

- 支持文件数 -

-

- {selectedLog.support_file_count} -

-
-
- -
-
-

- 主配置摘要 -

-

- {selectedLog.main_config_checksum || '无'} -

-
-
-

- 路由配置摘要 -

-

- {selectedLog.route_config_checksum || '无'} -

-
-
- -
-

- 应用信息 -

-

- {selectedLog.message || '无附加信息'} -

-
-
- ) : ( - - )} -
-
-
- ); -} +'use client'; + +import Link from 'next/link'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useMemo, useState } from 'react'; + +import { EmptyState } from '@/components/feedback/empty-state'; +import { ErrorState } from '@/components/feedback/error-state'; +import { InlineMessage } from '@/components/feedback/inline-message'; +import { LoadingState } from '@/components/feedback/loading-state'; +import { PageHeader } from '@/components/layout/page-header'; +import { AppCard } from '@/components/ui/app-card'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; +import type { ApplyLogItem } from '@/features/apply-logs/types'; +import { + PrimaryButton, + ResourceInput, + SecondaryButton, +} from '@/features/shared/components/resource-primitives'; +import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; + +const applyLogsQueryKey = (nodeId: string) => ['apply-logs', nodeId] as const; + +function getErrorMessage(error: unknown) { + return error instanceof Error ? error.message : '请求失败,请稍后重试。'; +} + +function getResultMeta(result: string) { + if (result === 'success') { + return { label: '成功', variant: 'success' as const }; + } + + if (result === 'warning') { + return { label: '警告', variant: 'warning' as const }; + } + + return { label: '失败', variant: 'danger' as const }; +} + +function buildSummary(logs: ApplyLogItem[]) { + const nodeIds = new Set(logs.map((item) => item.node_id)); + + return [ + { label: '记录总数', value: logs.length }, + { + label: '成功', + value: logs.filter((item) => item.result === 'success').length, + }, + { + label: '失败', + value: logs.filter((item) => item.result !== 'success').length, + }, + { label: '节点数', value: nodeIds.size }, + ]; +} + +function truncateHash(value: string) { + if (!value) { + return '—'; + } + + return value.length > 12 ? `${value.slice(0, 12)}...` : value; +} + +export function ApplyLogsPage() { + const queryClient = useQueryClient(); + const [nodeFilterInput, setNodeFilterInput] = useState(''); + const [nodeFilter, setNodeFilter] = useState(''); + const [selectedLogId, setSelectedLogId] = useState(null); + const [feedback, setFeedback] = useState(null); + + const logsQuery = useQuery({ + queryKey: applyLogsQueryKey(nodeFilter), + queryFn: () => getApplyLogs(nodeFilter), + }); + + const logs = useMemo(() => logsQuery.data ?? [], [logsQuery.data]); + const summary = useMemo(() => buildSummary(logs), [logs]); + + useEffect(() => { + if (logs.length === 0) { + setSelectedLogId(null); + return; + } + + if (!logs.some((item) => item.id === selectedLogId)) { + setSelectedLogId(logs[0].id); + } + }, [logs, selectedLogId]); + + const selectedLog = logs.find((item) => item.id === selectedLogId) ?? null; + + const handleSearch = () => { + setFeedback(null); + setNodeFilter(nodeFilterInput.trim()); + }; + + const handleReset = () => { + setFeedback(null); + setNodeFilter(''); + setNodeFilterInput(''); + }; + + return ( +
+ + 返回节点页 + + } + /> + + {feedback ? : null} + + +
+ {summary.map((item) => ( +
+

+ {item.label} +

+

+ {item.value} +

+
+ ))} +
+
+ +
+ + void queryClient.invalidateQueries({ + queryKey: applyLogsQueryKey(nodeFilter), + }) + } + > + 刷新 + + } + > +
+
+ setNodeFilterInput(event.target.value)} + placeholder="输入 node_id 过滤应用记录" + className="lg:max-w-md" + /> +
+ + 筛选 + + + 清空 + +
+
+ + {logsQuery.isLoading ? ( + + ) : logsQuery.isError ? ( + + ) : logs.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + {logs.map((log) => { + const resultMeta = getResultMeta(log.result); + return ( + + + + + + + + + ); + })} + +
Node ID版本结果Checksum时间详情
+ {log.node_id} + + {log.version} + + + + {truncateHash(log.checksum)} + +
+

{formatDateTime(log.created_at)}

+

+ {formatRelativeTime(log.created_at)} +

+
+
+ { + setSelectedLogId(log.id); + setFeedback( + `已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`, + ); + }} + className="px-3 py-2 text-xs" + > + 查看详情 + +
+
+ )} +
+
+ + + {selectedLog ? ( +
+
+ + + +
+ +
+
+

+ 创建时间 +

+

+ {formatDateTime(selectedLog.created_at)} +

+
+
+

+ 相对时间 +

+

+ {formatRelativeTime(selectedLog.created_at)} +

+
+
+

+ 目标 Checksum +

+

+ {selectedLog.checksum || '无'} +

+
+
+

+ 支持文件数 +

+

+ {selectedLog.support_file_count} +

+
+
+ +
+
+

+ 主配置摘要 +

+

+ {selectedLog.main_config_checksum || '无'} +

+
+
+

+ 路由配置摘要 +

+

+ {selectedLog.route_config_checksum || '无'} +

+
+
+ +
+

+ 应用信息 +

+

+ {selectedLog.message || '无附加信息'} +

+
+
+ ) : ( + + )} +
+
+
+ ); +} diff --git a/openflare_server/web/features/nodes/types.ts b/openflare_server/web/features/nodes/types.ts index e09ae954..1555f6b0 100644 --- a/openflare_server/web/features/nodes/types.ts +++ b/openflare_server/web/features/nodes/types.ts @@ -1,205 +1,205 @@ -import type { ReleaseChannel } from '@/features/update/types'; - -export interface NodeItem { - id: number; - node_id: string; - name: string; - ip: string; - geo_name: string; - geo_latitude?: number | null; - geo_longitude?: number | null; - geo_manual_override: boolean; - agent_token: string; - auto_update_enabled: boolean; - update_requested: boolean; - update_channel: ReleaseChannel; - update_tag: string; - restart_openresty_requested: boolean; - agent_version: string; - nginx_version: string; - openresty_status: 'healthy' | 'unhealthy' | 'unknown'; - openresty_message: string; - status: 'online' | 'offline' | 'pending'; - current_version: string; - last_seen_at: string; - last_error: string; - latest_apply_result: 'success' | 'failed' | ''; - latest_apply_message: string; - latest_apply_checksum: string; - latest_main_config_checksum: string; - latest_route_config_checksum: string; - latest_support_file_count: number; - latest_apply_at?: string | null; - created_at: string; - updated_at: string; -} - -export interface NodeBootstrapToken { - discovery_token: string; -} - -export interface NodeMutationPayload { - name: string; - ip: string; - auto_update_enabled: boolean; - geo_name: string; - geo_latitude?: number | null; - geo_longitude?: number | null; - geo_manual_override: boolean; -} - -export interface NodeAgentReleaseInfo { - tag_name: string; - body: string; - html_url: string; - published_at: string; - current_version: string; - has_update: boolean; - channel: ReleaseChannel; - prerelease: boolean; - update_requested: boolean; - requested_channel: ReleaseChannel; - requested_tag: string; -} - -export interface NodeAgentUpdatePayload { - channel?: ReleaseChannel; - tag_name?: string; -} - -export interface NodeSystemProfile { - hostname: string; - os_name: string; - os_version: string; - kernel_version: string; - architecture: string; - cpu_model: string; - cpu_cores: number; - total_memory_bytes: number; - total_disk_bytes: number; - uptime_seconds: number; - reported_at: string; -} - -export interface NodeMetricSnapshot { - captured_at: string; - cpu_usage_percent: number; - memory_used_bytes: number; - memory_total_bytes: number; - storage_used_bytes: number; - storage_total_bytes: number; - disk_read_bytes: number; - disk_write_bytes: number; - network_rx_bytes: number; - network_tx_bytes: number; - openresty_rx_bytes: number; - openresty_tx_bytes: number; - openresty_connections: number; -} - -export interface NodeTrafficReport { - window_started_at: string; - window_ended_at: string; - request_count: number; - error_count: number; - unique_visitor_count: number; - status_codes_json: string; - top_domains_json: string; - source_countries_json: string; -} - -export interface NodeTrafficTrendPoint { - bucket_started_at: string; - request_count: number; - error_count: number; - unique_visitor_count: number; -} - -export interface NodeCapacityTrendPoint { - bucket_started_at: string; - average_cpu_usage_percent: number; - average_memory_usage_percent: number; - reported_nodes: number; -} - -export interface NodeNetworkTrendPoint { - bucket_started_at: string; - network_rx_bytes: number; - network_tx_bytes: number; - openresty_rx_bytes: number; - openresty_tx_bytes: number; - reported_nodes: number; -} - -export interface NodeDiskIOTrendPoint { - bucket_started_at: string; - disk_read_bytes: number; - disk_write_bytes: number; - reported_nodes: number; -} - -export interface NodeDistributionItem { - key: string; - value: number; -} - -export interface NodeTrafficDistributions { - status_codes: NodeDistributionItem[]; - top_domains: NodeDistributionItem[]; - source_countries: NodeDistributionItem[]; -} - -export interface NodeTrafficSummary { - window_started_at: string; - window_ended_at: string; - request_count: number; - unique_visitor_count: number; - error_count: number; - estimated_qps: number; - error_rate_percent: number; -} - -export interface NodeHealthSummary { - active_alerts: number; - critical_alerts: number; - warning_alerts: number; - info_alerts: number; - resolved_alerts: number; - has_capacity_risk: boolean; - has_traffic_risk: boolean; - has_runtime_risk: boolean; -} - -export interface NodeObservabilityAnalytics { - traffic: NodeTrafficSummary; - distributions: NodeTrafficDistributions; - health: NodeHealthSummary; -} - -export interface NodeObservabilityTrends { - traffic_24h: NodeTrafficTrendPoint[]; - capacity_24h: NodeCapacityTrendPoint[]; - network_24h: NodeNetworkTrendPoint[]; - disk_io_24h: NodeDiskIOTrendPoint[]; -} - -export interface NodeHealthEvent { - event_type: string; - severity: string; - status: string; - message: string; - first_triggered_at: string; - last_triggered_at: string; - reported_at: string; - resolved_at?: string | null; -} - -export interface NodeObservability { - node_id: string; - profile: NodeSystemProfile | null; - metric_snapshots: NodeMetricSnapshot[]; - traffic_reports: NodeTrafficReport[]; - health_events: NodeHealthEvent[]; - analytics: NodeObservabilityAnalytics; - trends: NodeObservabilityTrends; -} +import type { ReleaseChannel } from '@/features/update/types'; + +export interface NodeItem { + id: number; + node_id: string; + name: string; + ip: string; + geo_name: string; + geo_latitude?: number | null; + geo_longitude?: number | null; + geo_manual_override: boolean; + agent_token: string; + auto_update_enabled: boolean; + update_requested: boolean; + update_channel: ReleaseChannel; + update_tag: string; + restart_openresty_requested: boolean; + agent_version: string; + nginx_version: string; + openresty_status: 'healthy' | 'unhealthy' | 'unknown'; + openresty_message: string; + status: 'online' | 'offline' | 'pending'; + current_version: string; + last_seen_at: string; + last_error: string; + latest_apply_result: 'success' | 'warning' | 'failed' | ''; + latest_apply_message: string; + latest_apply_checksum: string; + latest_main_config_checksum: string; + latest_route_config_checksum: string; + latest_support_file_count: number; + latest_apply_at?: string | null; + created_at: string; + updated_at: string; +} + +export interface NodeBootstrapToken { + discovery_token: string; +} + +export interface NodeMutationPayload { + name: string; + ip: string; + auto_update_enabled: boolean; + geo_name: string; + geo_latitude?: number | null; + geo_longitude?: number | null; + geo_manual_override: boolean; +} + +export interface NodeAgentReleaseInfo { + tag_name: string; + body: string; + html_url: string; + published_at: string; + current_version: string; + has_update: boolean; + channel: ReleaseChannel; + prerelease: boolean; + update_requested: boolean; + requested_channel: ReleaseChannel; + requested_tag: string; +} + +export interface NodeAgentUpdatePayload { + channel?: ReleaseChannel; + tag_name?: string; +} + +export interface NodeSystemProfile { + hostname: string; + os_name: string; + os_version: string; + kernel_version: string; + architecture: string; + cpu_model: string; + cpu_cores: number; + total_memory_bytes: number; + total_disk_bytes: number; + uptime_seconds: number; + reported_at: string; +} + +export interface NodeMetricSnapshot { + captured_at: string; + cpu_usage_percent: number; + memory_used_bytes: number; + memory_total_bytes: number; + storage_used_bytes: number; + storage_total_bytes: number; + disk_read_bytes: number; + disk_write_bytes: number; + network_rx_bytes: number; + network_tx_bytes: number; + openresty_rx_bytes: number; + openresty_tx_bytes: number; + openresty_connections: number; +} + +export interface NodeTrafficReport { + window_started_at: string; + window_ended_at: string; + request_count: number; + error_count: number; + unique_visitor_count: number; + status_codes_json: string; + top_domains_json: string; + source_countries_json: string; +} + +export interface NodeTrafficTrendPoint { + bucket_started_at: string; + request_count: number; + error_count: number; + unique_visitor_count: number; +} + +export interface NodeCapacityTrendPoint { + bucket_started_at: string; + average_cpu_usage_percent: number; + average_memory_usage_percent: number; + reported_nodes: number; +} + +export interface NodeNetworkTrendPoint { + bucket_started_at: string; + network_rx_bytes: number; + network_tx_bytes: number; + openresty_rx_bytes: number; + openresty_tx_bytes: number; + reported_nodes: number; +} + +export interface NodeDiskIOTrendPoint { + bucket_started_at: string; + disk_read_bytes: number; + disk_write_bytes: number; + reported_nodes: number; +} + +export interface NodeDistributionItem { + key: string; + value: number; +} + +export interface NodeTrafficDistributions { + status_codes: NodeDistributionItem[]; + top_domains: NodeDistributionItem[]; + source_countries: NodeDistributionItem[]; +} + +export interface NodeTrafficSummary { + window_started_at: string; + window_ended_at: string; + request_count: number; + unique_visitor_count: number; + error_count: number; + estimated_qps: number; + error_rate_percent: number; +} + +export interface NodeHealthSummary { + active_alerts: number; + critical_alerts: number; + warning_alerts: number; + info_alerts: number; + resolved_alerts: number; + has_capacity_risk: boolean; + has_traffic_risk: boolean; + has_runtime_risk: boolean; +} + +export interface NodeObservabilityAnalytics { + traffic: NodeTrafficSummary; + distributions: NodeTrafficDistributions; + health: NodeHealthSummary; +} + +export interface NodeObservabilityTrends { + traffic_24h: NodeTrafficTrendPoint[]; + capacity_24h: NodeCapacityTrendPoint[]; + network_24h: NodeNetworkTrendPoint[]; + disk_io_24h: NodeDiskIOTrendPoint[]; +} + +export interface NodeHealthEvent { + event_type: string; + severity: string; + status: string; + message: string; + first_triggered_at: string; + last_triggered_at: string; + reported_at: string; + resolved_at?: string | null; +} + +export interface NodeObservability { + node_id: string; + profile: NodeSystemProfile | null; + metric_snapshots: NodeMetricSnapshot[]; + traffic_reports: NodeTrafficReport[]; + health_events: NodeHealthEvent[]; + analytics: NodeObservabilityAnalytics; + trends: NodeObservabilityTrends; +} diff --git a/openflare_server/web/features/nodes/utils.ts b/openflare_server/web/features/nodes/utils.ts index 8df976e4..54a1d154 100644 --- a/openflare_server/web/features/nodes/utils.ts +++ b/openflare_server/web/features/nodes/utils.ts @@ -1,161 +1,169 @@ -import type { NodeItem } from '@/features/nodes/types'; - -export function isMeaningfulTime(value: string | null | undefined) { - return Boolean(value) && !String(value).startsWith('0001-01-01'); -} - -export function getNodeStatusVariant(status: NodeItem['status']) { - if (status === 'online') { - return 'success'; - } - - if (status === 'pending') { - return 'warning'; - } - - return 'danger'; -} - -export function getNodeStatusLabel(status: NodeItem['status']) { - if (status === 'online') { - return '在线'; - } - - if (status === 'pending') { - return '待接入'; - } - - return '离线'; -} - -export function getApplyVariant(result: NodeItem['latest_apply_result']) { - if (result === 'success') { - return 'success'; - } - - if (result === 'failed') { - return 'danger'; - } - - return 'warning'; -} - -export function getApplyLabel(result: NodeItem['latest_apply_result']) { - if (result === 'success') { - return '成功'; - } - - if (result === 'failed') { - return '失败'; - } - - return '暂无'; -} - -export function getUpdateMode(node: NodeItem) { - if (node.update_requested) { - if (node.update_channel === 'preview') { - return { label: '等待预览更新', variant: 'warning' as const }; - } - - return { label: '等待更新', variant: 'warning' as const }; - } - - if (node.auto_update_enabled) { - return { label: '自动', variant: 'success' as const }; - } - - return { label: '手动', variant: 'info' as const }; -} - -export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) { - if (status === 'healthy') { - return 'success'; - } - - if (status === 'unhealthy') { - return 'danger'; - } - - return 'warning'; -} - -export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) { - if (status === 'healthy') { - return '健康'; - } - - if (status === 'unhealthy') { - return '异常'; - } - - return '未知'; -} - -function parseVersionParts(version: string) { - const normalized = version.trim().replace(/^v/i, ''); - if (!normalized || normalized.toLowerCase() === 'unknown') { - return null; - } - - return normalized.split('.').map((segment) => { - const matched = segment.trim().match(/^\d+/); - return matched ? Number.parseInt(matched[0], 10) : 0; - }); -} - -function isOlderVersion(current: string, target: string) { - const currentParts = parseVersionParts(current); - const targetParts = parseVersionParts(target); - if (!currentParts || !targetParts) { - return false; - } - - const maxLength = Math.max(currentParts.length, targetParts.length); - for (let index = 0; index < maxLength; index += 1) { - const currentPart = currentParts[index] ?? 0; - const targetPart = targetParts[index] ?? 0; - if (currentPart < targetPart) { - return true; - } - if (currentPart > targetPart) { - return false; - } - } - - return false; -} - -export function shouldShowManualUpdate( - agentVersion: string, - serverVersion: string, -) { - const normalizedServerVersion = serverVersion.trim(); - const normalizedAgentVersion = agentVersion.trim(); - - if ( - !normalizedServerVersion || - normalizedServerVersion.toLowerCase() === 'dev' || - !normalizedAgentVersion || - normalizedAgentVersion.toLowerCase() === 'unknown' - ) { - return false; - } - - return isOlderVersion(normalizedAgentVersion, normalizedServerVersion); -} - -export function getServerUrl(value: string) { - return value.trim().replace(/\/+$/, ''); -} - -const installerScriptUrl = - 'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh'; - -export function buildNodeInstallCommand(serverUrl: string, agentToken: string) { - return [ - `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, - ` --server-url ${serverUrl} \\`, - ` --agent-token ${agentToken}`, - ].join('\n'); -} +import type { NodeItem } from '@/features/nodes/types'; + +export function isMeaningfulTime(value: string | null | undefined) { + return Boolean(value) && !String(value).startsWith('0001-01-01'); +} + +export function getNodeStatusVariant(status: NodeItem['status']) { + if (status === 'online') { + return 'success'; + } + + if (status === 'pending') { + return 'warning'; + } + + return 'danger'; +} + +export function getNodeStatusLabel(status: NodeItem['status']) { + if (status === 'online') { + return '在线'; + } + + if (status === 'pending') { + return '待接入'; + } + + return '离线'; +} + +export function getApplyVariant(result: NodeItem['latest_apply_result']) { + if (result === 'success') { + return 'success'; + } + + if (result === 'warning') { + return 'warning'; + } + + if (result === 'failed') { + return 'danger'; + } + + return 'warning'; +} + +export function getApplyLabel(result: NodeItem['latest_apply_result']) { + if (result === 'success') { + return '成功'; + } + + if (result === 'warning') { + return '警告'; + } + + if (result === 'failed') { + return '失败'; + } + + return '暂无'; +} + +export function getUpdateMode(node: NodeItem) { + if (node.update_requested) { + if (node.update_channel === 'preview') { + return { label: '等待预览更新', variant: 'warning' as const }; + } + + return { label: '等待更新', variant: 'warning' as const }; + } + + if (node.auto_update_enabled) { + return { label: '自动', variant: 'success' as const }; + } + + return { label: '手动', variant: 'info' as const }; +} + +export function getOpenrestyStatusVariant(status: NodeItem['openresty_status']) { + if (status === 'healthy') { + return 'success'; + } + + if (status === 'unhealthy') { + return 'danger'; + } + + return 'warning'; +} + +export function getOpenrestyStatusLabel(status: NodeItem['openresty_status']) { + if (status === 'healthy') { + return '健康'; + } + + if (status === 'unhealthy') { + return '异常'; + } + + return '未知'; +} + +function parseVersionParts(version: string) { + const normalized = version.trim().replace(/^v/i, ''); + if (!normalized || normalized.toLowerCase() === 'unknown') { + return null; + } + + return normalized.split('.').map((segment) => { + const matched = segment.trim().match(/^\d+/); + return matched ? Number.parseInt(matched[0], 10) : 0; + }); +} + +function isOlderVersion(current: string, target: string) { + const currentParts = parseVersionParts(current); + const targetParts = parseVersionParts(target); + if (!currentParts || !targetParts) { + return false; + } + + const maxLength = Math.max(currentParts.length, targetParts.length); + for (let index = 0; index < maxLength; index += 1) { + const currentPart = currentParts[index] ?? 0; + const targetPart = targetParts[index] ?? 0; + if (currentPart < targetPart) { + return true; + } + if (currentPart > targetPart) { + return false; + } + } + + return false; +} + +export function shouldShowManualUpdate( + agentVersion: string, + serverVersion: string, +) { + const normalizedServerVersion = serverVersion.trim(); + const normalizedAgentVersion = agentVersion.trim(); + + if ( + !normalizedServerVersion || + normalizedServerVersion.toLowerCase() === 'dev' || + !normalizedAgentVersion || + normalizedAgentVersion.toLowerCase() === 'unknown' + ) { + return false; + } + + return isOlderVersion(normalizedAgentVersion, normalizedServerVersion); +} + +export function getServerUrl(value: string) { + return value.trim().replace(/\/+$/, ''); +} + +const installerScriptUrl = + 'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh'; + +export function buildNodeInstallCommand(serverUrl: string, agentToken: string) { + return [ + `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, + ` --server-url ${serverUrl} \\`, + ` --agent-token ${agentToken}`, + ].join('\n'); +}