feat(i18n): 推进多语言剩余切片闭环

- 补齐 DataGrid、DataViewer、DefinitionViewer、JVM 等模块多语言文案与回归测试
- 收口 JVM 前后端展示、诊断、监控和资源呈现相关多语言路径
- 更新六语言共享词典并保留 raw 边界
This commit is contained in:
tianqijiuyun-latiao
2026-06-16 12:40:33 +08:00
parent 558966a129
commit 5fc29a6fd3
69 changed files with 6551 additions and 1276 deletions

View File

@@ -5,7 +5,7 @@ import (
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"errors"
"path/filepath"
"strings"
"time"
@@ -39,6 +39,25 @@ type jvmPreviewConfirmationContext struct {
AfterVersion string `json:"afterVersion"`
}
type jvmPreviewConfirmationHashError struct {
key string
err error
}
func (e *jvmPreviewConfirmationHashError) Error() string {
if e == nil || e.err == nil {
return ""
}
return e.err.Error()
}
func (e *jvmPreviewConfirmationHashError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func buildJVMCapabilityError(mode string, cfg connection.ConnectionConfig, err error) jvm.Capability {
probeCfg := cfg
probeCfg.JVM.PreferredMode = mode
@@ -72,7 +91,7 @@ func resolveJVMProviderForMode(cfg connection.ConnectionConfig, mode string) (co
func (a *App) issueJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) {
contextHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview)
if err != nil {
return "", err
return "", a.localizeJVMPreviewConfirmationHashError(err)
}
token := uuid.NewString()
@@ -101,17 +120,17 @@ func (a *App) consumeJVMPreviewConfirmationToken(cfg connection.ConnectionConfig
}
if strings.TrimSpace(preview.ConfirmationToken) == "" {
return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交")
return errors.New(a.appText("jvm.backend.error.preview_confirmation_missing", nil))
}
token := strings.TrimSpace(req.ConfirmationToken)
if token == "" {
return fmt.Errorf("缺少确认令牌,请先完成预览确认")
return errors.New(a.appText("jvm.backend.error.confirmation_token_missing", nil))
}
expectedHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview)
if err != nil {
return err
return a.localizeJVMPreviewConfirmationHashError(err)
}
now := time.Now()
@@ -119,21 +138,21 @@ func (a *App) consumeJVMPreviewConfirmationToken(cfg connection.ConnectionConfig
if a.jvmPreviewTokens == nil {
a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken)
}
a.pruneExpiredJVMPreviewConfirmationTokensLocked(now)
entry, ok := a.jvmPreviewTokens[token]
if ok {
delete(a.jvmPreviewTokens, token)
}
a.pruneExpiredJVMPreviewConfirmationTokensLocked(now)
a.jvmPreviewTokenMu.Unlock()
if !ok {
return fmt.Errorf("确认令牌已失效,请重新预览并确认")
return errors.New(a.appText("jvm.backend.error.confirmation_token_invalid", nil))
}
if !entry.expiresAt.After(now) {
return fmt.Errorf("确认令牌已过期,请重新预览并确认")
return errors.New(a.appText("jvm.backend.error.confirmation_token_expired", nil))
}
if subtle.ConstantTimeCompare([]byte(entry.contextHash), []byte(expectedHash)) != 1 {
return fmt.Errorf("确认令牌不匹配,请重新预览并确认")
return errors.New(a.appText("jvm.backend.error.confirmation_token_invalid", nil))
}
return nil
}
@@ -149,11 +168,17 @@ func (a *App) pruneExpiredJVMPreviewConfirmationTokensLocked(now time.Time) {
func buildJVMPreviewConfirmationContextHash(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) {
configHash, err := hashJSONValue(cfg)
if err != nil {
return "", fmt.Errorf("生成 JVM 预览上下文失败: %w", err)
return "", &jvmPreviewConfirmationHashError{
key: "jvm.backend.error.preview_context_hash_failed",
err: err,
}
}
payloadHash, err := hashJSONValue(req.Payload)
if err != nil {
return "", fmt.Errorf("生成 JVM 预览载荷摘要失败: %w", err)
return "", &jvmPreviewConfirmationHashError{
key: "jvm.backend.error.preview_payload_hash_failed",
err: err,
}
}
input := jvmPreviewConfirmationContext{
@@ -173,6 +198,51 @@ func buildJVMPreviewConfirmationContextHash(cfg connection.ConnectionConfig, req
return hashJSONValue(input)
}
func (a *App) localizeJVMPreviewConfirmationHashError(err error) error {
var hashErr *jvmPreviewConfirmationHashError
if !errors.As(err, &hashErr) || hashErr == nil {
return err
}
return errors.New(a.appText(hashErr.key, map[string]any{
"detail": hashErr.Error(),
}))
}
func (a *App) localizeJVMError(err error) string {
if err == nil {
return ""
}
var localized *jvm.LocalizedError
if errors.As(err, &localized) && localized != nil && strings.TrimSpace(localized.Key) != "" {
return a.appText(localized.Key, localized.Params)
}
return err.Error()
}
func (a *App) localizeJVMBlockingReason(preview jvm.ChangePreview) string {
reason := strings.TrimSpace(preview.BlockingReason)
if reason == "" {
return ""
}
key := strings.TrimSpace(preview.BlockingReasonLocalizationKey())
if key != "" && reason == key {
return a.appText(key, nil)
}
return reason
}
func (a *App) localizeJVMCapability(capability jvm.Capability) jvm.Capability {
reason := strings.TrimSpace(capability.Reason)
if reason == "" {
return capability
}
key := strings.TrimSpace(capability.ReasonLocalizationKey())
if key != "" && reason == key {
capability.Reason = a.appText(key, nil)
}
return capability
}
func hashJSONValue(value any) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
@@ -192,7 +262,7 @@ func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.Quer
return connection.QueryResult{Success: false, Message: jvm.DescribeConnectionTestError(normalized, err)}
}
return connection.QueryResult{Success: true, Message: "JVM 连接成功"}
return connection.QueryResult{Success: true, Message: a.appText("jvm.backend.message.connect_success", nil)}
}
func (a *App) JVMListResources(cfg connection.ConnectionConfig, parentPath string) connection.QueryResult {
@@ -232,12 +302,12 @@ func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRe
normalized, provider, err := resolveJVMProviderForMode(cfg, req.ProviderMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
preview, err := jvm.BuildChangePreview(a.ctx, provider, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
if preview.Allowed && preview.RequiresConfirmation {
token, err := a.issueJVMPreviewConfirmationToken(normalized, req, preview)
@@ -246,6 +316,7 @@ func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRe
}
preview.ConfirmationToken = token
}
preview.BlockingReason = a.localizeJVMBlockingReason(preview)
return connection.QueryResult{Success: true, Data: preview}
}
@@ -259,17 +330,17 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ
normalized, provider, err := resolveJVMProviderForMode(cfg, req.ProviderMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
preview, err := jvm.BuildChangePreview(a.ctx, provider, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
if !preview.Allowed {
message := strings.TrimSpace(preview.BlockingReason)
message := a.localizeJVMBlockingReason(preview)
if message == "" {
message = "当前变更被 Guard 拦截"
message = a.appText("jvm.backend.error.change_blocked_by_guard", nil)
}
return connection.QueryResult{Success: false, Message: message}
}
@@ -302,7 +373,7 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ
if message == "" {
return warning
}
return message + "" + warning
return message + a.appText("jvm.backend.separator.message_warning", nil) + warning
}
pendingTimestamp := time.Now().UnixMilli()
@@ -315,13 +386,13 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ
}
if err := appendAudit("pending", pendingTimestamp); err != nil {
return connection.QueryResult{Success: false, Message: "审计记录写入失败,已阻止 JVM 变更: " + err.Error()}
return connection.QueryResult{Success: false, Message: a.appText("jvm.backend.error.audit_write_blocked", map[string]any{"detail": err.Error()})}
}
result, err := provider.ApplyChange(a.ctx, normalized, req)
if err != nil {
if auditErr := appendAudit("failed", terminalAuditTimestamp()); auditErr != nil {
return connection.QueryResult{Success: false, Message: err.Error() + ";失败审计写入失败: " + auditErr.Error()}
return connection.QueryResult{Success: false, Message: appendWarning(err.Error(), a.appText("jvm.backend.warning.failed_audit_write_failed", map[string]any{"detail": auditErr.Error()}))}
}
return connection.QueryResult{Success: false, Message: err.Error()}
}
@@ -331,7 +402,7 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ
terminalResult = "applied"
}
if err := appendAudit(terminalResult, terminalAuditTimestamp()); err != nil {
result.Message = appendWarning(result.Message, "终态审计写入失败: "+err.Error())
result.Message = appendWarning(result.Message, a.appText("jvm.backend.warning.terminal_audit_write_failed", map[string]any{"detail": err.Error()}))
return connection.QueryResult{Success: true, Message: result.Message, Data: result}
}
@@ -369,7 +440,9 @@ func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.Q
continue
}
items = append(items, caps...)
for _, cap := range caps {
items = append(items, a.localizeJVMCapability(cap))
}
}
return connection.QueryResult{Success: true, Data: items}

View File

@@ -39,7 +39,9 @@ func resolveJVMDiagnosticTransport(cfg connection.ConnectionConfig) (connection.
return connection.ConnectionConfig{}, nil, err
}
if !diagCfg.Enabled {
return connection.ConnectionConfig{}, nil, errors.New("当前连接未启用 JVM 诊断增强模式")
return connection.ConnectionConfig{}, nil, &jvm.LocalizedError{
Key: "jvm.backend.diagnostic.error.disabled",
}
}
normalized.JVM.Diagnostic = diagCfg
@@ -53,12 +55,12 @@ func resolveJVMDiagnosticTransport(cfg connection.ConnectionConfig) (connection.
func (a *App) JVMProbeDiagnosticCapabilities(cfg connection.ConnectionConfig) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
items, err := transport.ProbeCapabilities(a.ctx, normalized)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
return connection.QueryResult{Success: true, Data: items}
}
@@ -66,12 +68,12 @@ func (a *App) JVMProbeDiagnosticCapabilities(cfg connection.ConnectionConfig) co
func (a *App) JVMStartDiagnosticSession(cfg connection.ConnectionConfig, req jvm.DiagnosticSessionRequest) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
handle, err := transport.StartSession(a.ctx, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
return connection.QueryResult{Success: true, Data: handle}
}
@@ -79,7 +81,7 @@ func (a *App) JVMStartDiagnosticSession(cfg connection.ConnectionConfig, req jvm
func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID string, req jvm.DiagnosticCommandRequest) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
redactor := jvm.NewDiagnosticOutputRedactor()
@@ -91,10 +93,10 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
req.Reason = strings.TrimSpace(req.Reason)
if req.SessionID == "" {
return connection.QueryResult{Success: false, Message: "诊断会话 ID 不能为空,请先创建会话"}
return connection.QueryResult{Success: false, Message: a.appText("jvm.backend.diagnostic.error.session_id_required", nil)}
}
if req.Command == "" {
return connection.QueryResult{Success: false, Message: "诊断命令不能为空"}
return connection.QueryResult{Success: false, Message: a.appText("jvm.backend.diagnostic.error.command_required", nil)}
}
if req.CommandID == "" {
req.CommandID = fmt.Sprintf("diag-%d", time.Now().UnixNano())
@@ -105,7 +107,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
commandType, err := jvm.ValidateDiagnosticExecutionPolicy(normalized, req.Command)
if err != nil {
message := redactor.RedactContent(req.SessionID, req.CommandID, err.Error())
message := redactor.RedactContent(req.SessionID, req.CommandID, a.localizeJVMError(err))
return connection.QueryResult{Success: false, Message: message}
}
riskLevel := diagnosticRiskLevel(commandType)
@@ -124,7 +126,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
RiskLevel: riskLevel,
Status: "running",
}); err != nil {
return connection.QueryResult{Success: false, Message: "诊断审计记录写入失败,已阻止命令执行: " + err.Error()}
return connection.QueryResult{Success: false, Message: a.appText("jvm.backend.diagnostic.error.audit_write_blocked", map[string]any{"detail": err.Error()})}
}
terminalSeen := false
@@ -145,7 +147,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
RiskLevel: riskLevel,
Status: status,
}); err != nil {
auditWarnings = append(auditWarnings, "审计记录写入失败: "+err.Error())
auditWarnings = append(auditWarnings, a.appText("jvm.backend.diagnostic.warning.audit_write_failed", map[string]any{"detail": err.Error()}))
}
}
@@ -156,6 +158,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
}
chunk.SessionID = req.SessionID
chunk.CommandID = req.CommandID
chunk = a.localizeDiagnosticChunkContent(chunk)
chunk = redactor.RedactChunk(chunk)
a.emitDiagnosticChunk(tabID, chunk)
if isDiagnosticTerminalPhase(chunk.Phase) {
@@ -166,10 +169,10 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
if err := transport.ExecuteCommand(a.ctx, normalized, req); err != nil {
phase := "failed"
if strings.Contains(strings.ToLower(err.Error()), "canceled") {
if isDiagnosticCanceledError(err) {
phase = "canceled"
}
redactedError := redactor.RedactContent(req.SessionID, req.CommandID, err.Error())
redactedError := redactor.RedactContent(req.SessionID, req.CommandID, a.localizeJVMError(err))
if !terminalSeen {
chunk := jvm.DiagnosticEventChunk{
SessionID: req.SessionID,
@@ -182,7 +185,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
a.emitDiagnosticChunk(tabID, chunk)
appendTerminalAudit(phase)
}
return connection.QueryResult{Success: false, Message: joinDiagnosticMessages(redactedError, auditWarnings)}
return connection.QueryResult{Success: false, Message: a.joinDiagnosticMessages(redactedError, auditWarnings)}
}
if !terminalSeen {
@@ -191,7 +194,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
CommandID: req.CommandID,
Event: "diagnostic",
Phase: "completed",
Content: "诊断命令执行完成",
Content: a.appText("jvm.backend.diagnostic.message.command_completed", nil),
Timestamp: time.Now().UnixMilli(),
}
a.emitDiagnosticChunk(tabID, chunk)
@@ -200,7 +203,7 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
return connection.QueryResult{
Success: true,
Message: joinDiagnosticMessages("", auditWarnings),
Message: a.joinDiagnosticMessages("", auditWarnings),
Data: map[string]any{
"sessionId": req.SessionID,
"commandId": req.CommandID,
@@ -212,17 +215,17 @@ func (a *App) JVMExecuteDiagnosticCommand(cfg connection.ConnectionConfig, tabID
func (a *App) JVMCancelDiagnosticCommand(cfg connection.ConnectionConfig, tabID string, sessionID string, commandID string) connection.QueryResult {
normalized, transport, err := resolveJVMDiagnosticTransport(cfg)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
sessionID = strings.TrimSpace(sessionID)
commandID = strings.TrimSpace(commandID)
if sessionID == "" || commandID == "" {
return connection.QueryResult{Success: false, Message: "取消命令缺少 sessionId 或 commandId"}
return connection.QueryResult{Success: false, Message: a.appText("jvm.backend.diagnostic.error.cancel_identifiers_required", nil)}
}
if err := transport.CancelCommand(a.ctx, normalized, sessionID, commandID); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
a.emitDiagnosticChunk(tabID, jvm.DiagnosticEventChunk{
@@ -230,7 +233,7 @@ func (a *App) JVMCancelDiagnosticCommand(cfg connection.ConnectionConfig, tabID
CommandID: commandID,
Event: "diagnostic",
Phase: "canceling",
Content: "已发送取消请求,等待诊断桥接端结束命令",
Content: a.appText("jvm.backend.diagnostic.message.cancel_requested", nil),
Timestamp: time.Now().UnixMilli(),
})
return connection.QueryResult{
@@ -261,6 +264,24 @@ func (a *App) emitDiagnosticChunk(tabID string, chunk jvm.DiagnosticEventChunk)
})
}
func (a *App) localizeDiagnosticChunkContent(chunk jvm.DiagnosticEventChunk) jvm.DiagnosticEventChunk {
if chunk.Metadata == nil {
return chunk
}
contentKey, ok := chunk.Metadata["contentKey"].(string)
contentKey = strings.TrimSpace(contentKey)
if !ok || contentKey == "" {
return chunk
}
var params map[string]any
if rawParams, ok := chunk.Metadata["contentParams"].(map[string]any); ok {
params = rawParams
}
chunk.Content = a.appText(contentKey, params)
return chunk
}
func diagnosticRiskLevel(commandType string) string {
switch strings.TrimSpace(commandType) {
case jvm.DiagnosticCommandCategoryObserve:
@@ -272,6 +293,20 @@ func diagnosticRiskLevel(commandType string) string {
}
}
func isDiagnosticCanceledError(err error) bool {
if err == nil {
return false
}
var localized *jvm.LocalizedError
if errors.As(err, &localized) && localized != nil {
switch strings.TrimSpace(localized.Key) {
case "jvm.backend.diagnostic.arthas.command_canceled":
return true
}
}
return strings.Contains(strings.ToLower(err.Error()), "canceled")
}
func isDiagnosticTerminalPhase(phase string) bool {
switch strings.ToLower(strings.TrimSpace(phase)) {
case "completed", "failed", "canceled":
@@ -281,7 +316,7 @@ func isDiagnosticTerminalPhase(phase string) bool {
}
}
func joinDiagnosticMessages(primary string, warnings []string) string {
func (a *App) joinDiagnosticMessages(primary string, warnings []string) string {
items := make([]string, 0, 1+len(warnings))
if strings.TrimSpace(primary) != "" {
items = append(items, strings.TrimSpace(primary))
@@ -292,5 +327,5 @@ func joinDiagnosticMessages(primary string, warnings []string) string {
}
items = append(items, strings.TrimSpace(warning))
}
return strings.Join(items, "")
return strings.Join(items, a.appText("jvm.backend.separator.message_warning", nil))
}

View File

@@ -20,6 +20,7 @@ type fakeDiagnosticTransport struct {
startErr error
executeReq jvm.DiagnosticCommandRequest
executeErr error
executeHook func()
executeCalls int
cancelSession string
cancelCommand string
@@ -41,6 +42,9 @@ func (f fakeDiagnosticTransport) StartSession(context.Context, connection.Connec
}
func (f fakeDiagnosticTransport) ExecuteCommand(context.Context, connection.ConnectionConfig, jvm.DiagnosticCommandRequest) error {
if f.executeHook != nil {
f.executeHook()
}
return f.executeErr
}
@@ -101,6 +105,25 @@ func (f *fakeStreamingDiagnosticTransport) CloseSession(context.Context, connect
return nil
}
func captureJVMDiagnosticChunks(t *testing.T, app *App) (*[]diagnosticChunkEventPayload, func()) {
t.Helper()
app.ctx = context.Background()
emitted := make([]diagnosticChunkEventPayload, 0, 2)
prevEmitter := emitJVMDiagnosticRuntimeEvent
emitJVMDiagnosticRuntimeEvent = func(ctx context.Context, eventName string, optionalData ...interface{}) {
if eventName != diagnosticChunkEvent {
return
}
payload, ok := optionalData[0].(diagnosticChunkEventPayload)
if !ok {
t.Fatalf("expected diagnostic chunk event payload, got %#v", optionalData[0])
}
emitted = append(emitted, payload)
}
return &emitted, func() { emitJVMDiagnosticRuntimeEvent = prevEmitter }
}
func TestJVMProbeDiagnosticCapabilitiesReturnsTransportPayload(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
@@ -178,6 +201,365 @@ func TestJVMStartDiagnosticSessionReturnsHandle(t *testing.T) {
}
}
func TestJVMDiagnosticStaticMessagesUseEnglishAppText(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
app.configDir = t.TempDir()
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return fakeDiagnosticTransport{}, nil
})
defer restore()
disabledRes := app.JVMProbeDiagnosticCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: false,
},
},
})
if disabledRes.Success || disabledRes.Message != "JVM diagnostic enhancement is not enabled for this connection" {
t.Fatalf("expected localized disabled message, got %+v", disabledRes)
}
cfg := connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}
sessionRes := app.JVMExecuteDiagnosticCommand(cfg, "tab-diag-1", jvm.DiagnosticCommandRequest{
Command: "thread -n 5",
})
if sessionRes.Success || sessionRes.Message != "Diagnostic session ID is required. Create a session first." {
t.Fatalf("expected localized session required message, got %+v", sessionRes)
}
commandRes := app.JVMExecuteDiagnosticCommand(cfg, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
})
if commandRes.Success || commandRes.Message != "Diagnostic command cannot be empty" {
t.Fatalf("expected localized command required message, got %+v", commandRes)
}
cancelRes := app.JVMCancelDiagnosticCommand(cfg, "tab-diag-1", "sess-1", " ")
if cancelRes.Success || cancelRes.Message != "Cancel command requires sessionId and commandId" {
t.Fatalf("expected localized cancel identifiers message, got %+v", cancelRes)
}
}
func TestJVMExecuteDiagnosticCommandLocalizesAuditWriteBlockedWithRawDetail(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
tempDir := t.TempDir()
blockerPath := filepath.Join(tempDir, "audit-blocker")
if err := os.WriteFile(blockerPath, []byte("blocker"), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
app.configDir = blockerPath
recorder := &fakeDiagnosticTransport{}
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return diagnosticTransportRecorder{recorder: recorder}, nil
})
defer restore()
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-observe-audit",
Command: "thread -n 5",
Source: "manual",
Reason: "observe threads",
})
expectedPrefix := "Failed to write diagnostic audit record, command execution was blocked: "
if res.Success || !strings.HasPrefix(res.Message, expectedPrefix) {
t.Fatalf("expected localized audit blocked message, got %+v", res)
}
if !strings.Contains(res.Message, blockerPath) {
t.Fatalf("expected raw audit error detail to include %q, got %q", blockerPath, res.Message)
}
if recorder.executeCalls != 0 {
t.Fatalf("expected transport ExecuteCommand not called, got %d", recorder.executeCalls)
}
}
func TestJVMExecuteDiagnosticCommandLocalizesAuditWarningWithEnglishSeparatorAndRawErrors(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
tempDir := t.TempDir()
app.configDir = filepath.Join(tempDir, "audit-root")
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return fakeDiagnosticTransport{
executeErr: errors.New("bridge exploded"),
executeHook: func() {
if err := os.RemoveAll(app.configDir); err != nil {
t.Fatalf("RemoveAll returned error: %v", err)
}
if err := os.WriteFile(app.configDir, []byte("terminal-blocker"), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
},
}, nil
})
defer restore()
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-observe-warning",
Command: "thread -n 5",
Source: "manual",
Reason: "observe threads",
})
if res.Success {
t.Fatalf("expected execute failure, got %+v", res)
}
if !strings.HasPrefix(res.Message, "bridge exploded; Failed to write audit record: ") {
t.Fatalf("expected raw execute error and localized warning joined by English separator, got %q", res.Message)
}
if !strings.Contains(res.Message, app.configDir) {
t.Fatalf("expected raw audit warning detail to include %q, got %q", app.configDir, res.Message)
}
}
func TestJVMExecuteDiagnosticCommandLocalizesTransportLocalizedError(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
app.configDir = t.TempDir()
emitted, restoreEmitter := captureJVMDiagnosticChunks(t, app)
defer restoreEmitter()
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return fakeDiagnosticTransport{
executeErr: &jvm.LocalizedError{
Key: "jvm.backend.diagnostic.arthas.base_url_invalid",
Params: map[string]any{"detail": "://raw-bad-url"},
},
}, nil
})
defer restore()
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-localized-error",
Command: "thread -n 5",
Source: "manual",
Reason: "observe threads",
})
if res.Success {
t.Fatalf("expected execute failure, got %+v", res)
}
if res.Message != "Arthas Tunnel address is invalid: ://raw-bad-url" {
t.Fatalf("expected localized transport error with raw detail, got %q", res.Message)
}
if strings.Contains(res.Message, "jvm.backend.diagnostic.arthas.base_url_invalid") {
t.Fatalf("expected user message not to expose i18n key, got %q", res.Message)
}
if len(*emitted) != 1 {
t.Fatalf("expected one failed chunk, got %#v", *emitted)
}
if (*emitted)[0].Chunk.Content != res.Message {
t.Fatalf("expected failed chunk content to match localized message, got %#v", (*emitted)[0].Chunk)
}
}
func TestJVMExecuteDiagnosticCommandLocalizesChunkContentKeyAndKeepsRunningOutputRaw(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
app.configDir = t.TempDir()
emitted, restoreEmitter := captureJVMDiagnosticChunks(t, app)
defer restoreEmitter()
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return &fakeStreamingDiagnosticTransport{
chunks: []jvm.DiagnosticEventChunk{
{
Event: "diagnostic",
Phase: "running",
Content: "Arthas raw output: 中文 and targetId=orders-prod-01",
},
{
Event: "diagnostic",
Phase: "completed",
Content: "",
Metadata: map[string]any{
"transport": jvm.DiagnosticTransportArthasTunnel,
"contentKey": "jvm.backend.diagnostic.message.arthas_command_completed",
"rawTargetID": "orders-prod-01",
},
},
},
}, nil
})
defer restore()
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-content-key",
Command: "thread -n 5",
Source: "manual",
Reason: "observe threads",
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
if len(*emitted) != 2 {
t.Fatalf("expected running and completed chunks, got %#v", *emitted)
}
if (*emitted)[0].Chunk.Content != "Arthas raw output: 中文 and targetId=orders-prod-01" {
t.Fatalf("expected running output to remain raw, got %#v", (*emitted)[0].Chunk)
}
completed := (*emitted)[1].Chunk
if completed.Content != "Arthas command completed" {
t.Fatalf("expected contentKey-localized completed content, got %#v", completed)
}
if completed.Metadata["contentKey"] != "jvm.backend.diagnostic.message.arthas_command_completed" {
t.Fatalf("expected contentKey metadata to be preserved, got %#v", completed.Metadata)
}
if completed.Metadata["rawTargetID"] != "orders-prod-01" {
t.Fatalf("expected raw metadata to be preserved, got %#v", completed.Metadata)
}
}
func TestJVMExecuteDiagnosticCommandEmitsEnglishCompletedChunk(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
app.configDir = t.TempDir()
emitted, restoreEmitter := captureJVMDiagnosticChunks(t, app)
defer restoreEmitter()
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return fakeDiagnosticTransport{}, nil
})
defer restore()
res := app.JVMExecuteDiagnosticCommand(connection.ConnectionConfig{
ID: "conn-orders",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
AllowObserveCommands: true,
},
},
}, "tab-diag-1", jvm.DiagnosticCommandRequest{
SessionID: "sess-1",
CommandID: "cmd-completed",
Command: "thread -n 5",
Source: "manual",
Reason: "observe threads",
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
if len(*emitted) != 1 {
t.Fatalf("expected one completed chunk, got %#v", *emitted)
}
if (*emitted)[0].Chunk.Content != "Diagnostic command completed" {
t.Fatalf("expected localized completed chunk content, got %#v", (*emitted)[0].Chunk)
}
}
func TestJVMCancelDiagnosticCommandEmitsEnglishCancelChunk(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
emitted, restoreEmitter := captureJVMDiagnosticChunks(t, app)
defer restoreEmitter()
restore := swapJVMDiagnosticTransportFactory(func(mode string) (jvm.DiagnosticTransport, error) {
return fakeDiagnosticTransport{}, nil
})
defer restore()
res := app.JVMCancelDiagnosticCommand(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: jvm.DiagnosticTransportAgentBridge,
BaseURL: "http://127.0.0.1:19091/gonavi/diag",
},
},
}, "tab-diag-1", "sess-1", "cmd-1")
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
if len(*emitted) != 1 {
t.Fatalf("expected one cancel chunk, got %#v", *emitted)
}
if (*emitted)[0].Chunk.Content != "Cancel request sent; waiting for the diagnostic bridge to stop the command" {
t.Fatalf("expected localized cancel chunk content, got %#v", (*emitted)[0].Chunk)
}
}
func TestJVMExecuteDiagnosticCommandReturnsAccepted(t *testing.T) {
app := NewAppWithSecretStore(nil)
recorder := &fakeDiagnosticTransport{}
@@ -248,7 +630,7 @@ func TestJVMExecuteDiagnosticCommandBlocksTraceWhenConnectionReadOnly(t *testing
if res.Success {
t.Fatalf("expected trace command to be blocked in read-only mode, got %+v", res)
}
if !strings.Contains(res.Message, "只读") {
if !strings.Contains(res.Message, "read-only") {
t.Fatalf("expected read-only message, got %+v", res)
}
if recorder.executeCalls != 0 {
@@ -290,7 +672,7 @@ func TestJVMExecuteDiagnosticCommandBlocksMutatingWhenConnectionReadOnly(t *test
if res.Success {
t.Fatalf("expected mutating command to be blocked in read-only mode, got %+v", res)
}
if !strings.Contains(res.Message, "只读") {
if !strings.Contains(res.Message, "read-only") {
t.Fatalf("expected read-only message, got %+v", res)
}
if recorder.executeCalls != 0 {
@@ -617,7 +999,7 @@ func TestJVMExecuteDiagnosticCommandFailsClosedWhenAuditWriteFails(t *testing.T)
if res.Success {
t.Fatalf("expected command to fail closed when initial audit write fails, got %+v", res)
}
if !strings.Contains(res.Message, "审计") {
if !strings.Contains(res.Message, "audit") {
t.Fatalf("expected audit failure message, got %+v", res)
}
if recorder.executeCalls != 0 {

View File

@@ -20,32 +20,32 @@ var currentJVMMonitoringManager jvmMonitoringService = jvm.NewMonitoringManager(
func (a *App) JVMStartMonitoring(cfg connection.ConnectionConfig) connection.QueryResult {
snapshot, err := currentJVMMonitoringManager.Start(a.ctx, cfg, "")
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
return connection.QueryResult{Success: true, Data: snapshot}
return connection.QueryResult{Success: true, Data: a.localizeJVMMonitoringSnapshot(snapshot)}
}
func (a *App) JVMGetMonitoringHistory(cfg connection.ConnectionConfig, providerMode string) connection.QueryResult {
connectionID, resolvedMode, err := resolveJVMMonitoringLookup(cfg, providerMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
snapshot, err := currentJVMMonitoringManager.GetHistory(connectionID, resolvedMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
return connection.QueryResult{Success: true, Data: snapshot}
return connection.QueryResult{Success: true, Data: a.localizeJVMMonitoringSnapshot(snapshot)}
}
func (a *App) JVMStopMonitoring(cfg connection.ConnectionConfig, providerMode string) connection.QueryResult {
connectionID, resolvedMode, err := resolveJVMMonitoringLookup(cfg, providerMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
if err := currentJVMMonitoringManager.Stop(connectionID, resolvedMode); err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
return connection.QueryResult{Success: false, Message: a.localizeJVMError(err)}
}
return connection.QueryResult{Success: true, Data: map[string]any{
"connectionId": connectionID,
@@ -54,6 +54,23 @@ func (a *App) JVMStopMonitoring(cfg connection.ConnectionConfig, providerMode st
}}
}
func (a *App) localizeJVMMonitoringSnapshot(snapshot jvm.MonitoringSessionSnapshot) jvm.MonitoringSessionSnapshot {
if len(snapshot.ProviderWarnings) == 0 {
return snapshot
}
warnings := append([]string(nil), snapshot.ProviderWarnings...)
for index, warning := range warnings {
key, params, ok := jvm.ParseMonitoringProviderWarning(warning)
if !ok {
continue
}
warnings[index] = a.appText(key, params)
}
snapshot.ProviderWarnings = warnings
return snapshot
}
func resolveJVMMonitoringLookup(cfg connection.ConnectionConfig, requestedMode string) (string, string, error) {
normalized, resolvedMode, err := jvm.ResolveProviderMode(cfg, requestedMode)
if err != nil {

View File

@@ -3,6 +3,7 @@ package app
import (
"context"
"errors"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
@@ -145,3 +146,165 @@ func TestJVMStopMonitoringReturnsManagerError(t *testing.T) {
t.Fatalf("unexpected manager stop args: connection=%q mode=%q", manager.stopConnection, manager.stopMode)
}
}
func TestJVMMonitoringMethodsLocalizeManagerLocalizedErrors(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
manager := &fakeJVMMonitoringManager{
startErr: &jvm.LocalizedError{
Key: "jvm.backend.monitoring.error.snapshot_unsupported",
Params: map[string]any{
"provider": "JMX",
},
},
historyErr: &jvm.LocalizedError{
Key: "jvm.backend.monitoring.error.session_not_found",
Params: map[string]any{
"connectionId": "conn-history",
"providerMode": jvm.ModeJMX,
},
},
stopErr: &jvm.LocalizedError{
Key: "jvm.backend.monitoring.error.session_not_found",
Params: map[string]any{
"connectionId": "conn-stop",
"providerMode": jvm.ModeAgent,
},
},
}
restore := swapJVMMonitoringManager(manager)
defer restore()
startRes := app.JVMStartMonitoring(connection.ConnectionConfig{
ID: "conn-monitor",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: jvm.ModeJMX,
AllowedModes: []string{jvm.ModeJMX},
},
})
assertMonitoringEnglishMessage(t, startRes, "JMX monitoring snapshot is not supported yet")
historyRes := app.JVMGetMonitoringHistory(connection.ConnectionConfig{
ID: "conn-history",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: jvm.ModeJMX,
AllowedModes: []string{jvm.ModeJMX},
},
}, "")
assertMonitoringEnglishMessage(t, historyRes, "Monitoring session not found for conn-history jmx")
stopRes := app.JVMStopMonitoring(connection.ConnectionConfig{
ID: "conn-stop",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: jvm.ModeAgent,
AllowedModes: []string{jvm.ModeAgent},
},
}, "")
assertMonitoringEnglishMessage(t, stopRes, "Monitoring session not found for conn-stop agent")
}
func TestJVMMonitoringMethodsLocalizeStructuredProviderWarnings(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
manager := &fakeJVMMonitoringManager{
startSnapshot: jvm.MonitoringSessionSnapshot{
ConnectionID: "conn-monitor",
ProviderMode: jvm.ModeJMX,
Running: false,
ProviderWarnings: []string{
"endpoint cpu metric unavailable",
"__gonavi_i18n__:jvm.backend.monitoring.warning.sample_auto_stopped:count=3",
},
},
historySnapshot: jvm.MonitoringSessionSnapshot{
ConnectionID: "conn-monitor",
ProviderMode: jvm.ModeJMX,
Running: false,
ProviderWarnings: []string{
"__gonavi_i18n__:jvm.backend.monitoring.warning.sample_auto_stopped:count=3",
"collector returned HTTP 503",
},
},
}
restore := swapJVMMonitoringManager(manager)
defer restore()
startRes := app.JVMStartMonitoring(connection.ConnectionConfig{
ID: "conn-monitor",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: jvm.ModeJMX,
AllowedModes: []string{jvm.ModeJMX},
},
})
startSnapshot := assertMonitoringSnapshot(t, startRes)
assertMonitoringWarnings(t, startSnapshot.ProviderWarnings, []string{
"endpoint cpu metric unavailable",
"Monitoring sampling failed 3 consecutive times and this session was stopped automatically",
})
historyRes := app.JVMGetMonitoringHistory(connection.ConnectionConfig{
ID: "conn-monitor",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: jvm.ModeJMX,
AllowedModes: []string{jvm.ModeJMX},
},
}, "")
historySnapshot := assertMonitoringSnapshot(t, historyRes)
assertMonitoringWarnings(t, historySnapshot.ProviderWarnings, []string{
"Monitoring sampling failed 3 consecutive times and this session was stopped automatically",
"collector returned HTTP 503",
})
}
func assertMonitoringEnglishMessage(t *testing.T, res connection.QueryResult, want string) {
t.Helper()
if res.Success {
t.Fatalf("expected monitoring method to fail, got %+v", res)
}
if res.Message != want {
t.Fatalf("expected monitoring message %q, got %+v", want, res)
}
if strings.Contains(res.Message, "jvm.backend.") {
t.Fatalf("expected localized message instead of raw key, got %q", res.Message)
}
}
func assertMonitoringSnapshot(t *testing.T, res connection.QueryResult) jvm.MonitoringSessionSnapshot {
t.Helper()
if !res.Success {
t.Fatalf("expected monitoring method to succeed, got %+v", res)
}
snapshot, ok := res.Data.(jvm.MonitoringSessionSnapshot)
if !ok {
t.Fatalf("expected monitoring snapshot, got %#v", res.Data)
}
return snapshot
}
func assertMonitoringWarnings(t *testing.T, got []string, want []string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("expected warnings %#v, got %#v", want, got)
}
for index, expected := range want {
if got[index] != expected {
t.Fatalf("expected warning %d to be %q, got %#v", index, expected, got)
}
if strings.Contains(got[index], "jvm.backend.") {
t.Fatalf("expected localized warning instead of raw key, got %#v", got)
}
}
}

View File

@@ -85,8 +85,40 @@ func forceAuditAppendFailureAfterPending(t *testing.T, auditDir string) {
}
}
func expectedAuditAppendError(t *testing.T, auditRoot string) string {
t.Helper()
err := jvm.NewAuditStore(filepath.Join(auditRoot, "jvm_audit.jsonl")).Append(jvm.AuditRecord{
Timestamp: 1,
ConnectionID: "conn-orders",
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "expected raw audit append error",
Result: "pending",
})
if err == nil {
t.Fatalf("expected audit append to fail for %q", auditRoot)
}
return err.Error()
}
func assertEnglishJVMConfirmationMessage(t *testing.T, got string, want string) {
t.Helper()
if got != want {
t.Fatalf("expected English confirmation message %q, got %q", want, got)
}
for _, text := range []string{"确认", "令牌", "预览", "重新"} {
if strings.Contains(got, text) {
t.Fatalf("expected no Chinese confirmation text %q in message %q", text, got)
}
}
}
func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
var gotMode string
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
gotMode = mode
@@ -109,8 +141,8 @@ func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) {
if gotMode != "endpoint" {
t.Fatalf("expected provider mode endpoint, got %q", gotMode)
}
if res.Message != "JVM 连接成功" {
t.Fatalf("expected success message %q, got %q", "JVM 连接成功", res.Message)
if res.Message != "JVM connection succeeded" {
t.Fatalf("expected success message %q, got %q", "JVM connection succeeded", res.Message)
}
}
@@ -277,6 +309,95 @@ func TestJVMProbeCapabilitiesIncludesReasonWhenProbeFails(t *testing.T) {
}
}
func TestJVMProbeCapabilitiesLocalizesBuiltInReadOnlyReasons(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
restore := swapJVMProviderFactory(jvm.NewProvider)
defer restore()
readOnly := true
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
Timeout: 3,
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx", "endpoint", "agent"},
JMX: connection.JVMJMXConfig{
Host: "127.0.0.1",
Port: 9010,
},
Endpoint: connection.JVMEndpointConfig{
BaseURL: "https://orders.internal/manage/jvm",
TimeoutSeconds: 3,
},
Agent: connection.JVMAgentConfig{
BaseURL: "https://orders.internal/agent",
TimeoutSeconds: 3,
},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.Capability)
if !ok || len(items) != 3 {
t.Fatalf("expected three capabilities, got %#v", res.Data)
}
for _, item := range items {
if item.CanWrite {
t.Fatalf("expected read-only capability for %s, got %#v", item.Mode, item)
}
if item.Reason != "Current connection is read-only, so writes are blocked" {
t.Fatalf("expected localized read-only reason for %s, got %#v", item.Mode, item)
}
if strings.Contains(item.Reason, "jvm.backend.") || strings.Contains(item.Reason, "只读") {
t.Fatalf("expected no key or Chinese text in reason for %s, got %q", item.Mode, item.Reason)
}
}
}
func TestJVMProbeCapabilitiesKeepsProviderReasonThatLooksLikeCatalogKeyRaw(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
providerReason := "jvm.backend.error.change_blocked_read_only"
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
probe: []jvm.Capability{{
Mode: jvm.ModeJMX,
CanBrowse: true,
CanWrite: false,
CanPreview: true,
DisplayLabel: "JMX",
Reason: providerReason,
}},
}, nil
})
defer restore()
res := app.JVMProbeCapabilities(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
items, ok := res.Data.([]jvm.Capability)
if !ok || len(items) != 1 {
t.Fatalf("expected one capability, got %#v", res.Data)
}
if items[0].Reason != providerReason {
t.Fatalf("expected provider reason to stay raw, got %#v", items[0])
}
}
func TestJVMProbeCapabilitiesTranslatesJMXProbeErrorUsingCurrentMode(t *testing.T) {
app := NewAppWithSecretStore(nil)
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
@@ -480,6 +601,7 @@ func TestJVMGetValueReturnsProviderPayload(t *testing.T) {
func TestJVMApplyChangeRequiresConfirmationTokenForHighRiskPreview(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
app.configDir = t.TempDir()
readOnly := false
var applyReq jvm.ChangeRequest
@@ -529,9 +651,7 @@ func TestJVMApplyChangeRequiresConfirmationTokenForHighRiskPreview(t *testing.T)
if res.Success {
t.Fatalf("expected missing confirmation token to fail, got %+v", res)
}
if !strings.Contains(res.Message, "确认") && !strings.Contains(res.Message, "重新预览") {
t.Fatalf("expected confirmation guidance message, got %q", res.Message)
}
assertEnglishJVMConfirmationMessage(t, res.Message, "Confirmation token is missing. Complete preview confirmation first.")
if applyReq.ResourceID != "" {
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
}
@@ -601,6 +721,212 @@ func TestJVMApplyChangeReturnsProviderPayload(t *testing.T) {
}
}
func TestJVMApplyChangeUsesEnglishGuardFallbackWhenBlockingReasonEmpty(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
app.configDir = t.TempDir()
readOnly := false
var applyReq jvm.ChangeRequest
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
value: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
},
previewSet: true,
preview: jvm.ChangePreview{
Allowed: false,
},
applyReq: &applyReq,
apply: jvm.ApplyResult{Status: "applied"},
}, nil
})
defer restore()
res := app.JVMApplyChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{"status": "ready"},
})
if res.Success {
t.Fatalf("expected guard-blocked apply to fail, got %+v", res)
}
if res.Message != "The current change was blocked by Guard" {
t.Fatalf("expected English guard fallback message, got %q", res.Message)
}
if applyReq.ResourceID != "" {
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
}
}
func TestJVMProviderBlockingReasonThatLooksLikeCatalogKeyStaysRaw(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
app.configDir = t.TempDir()
readOnly := false
providerReason := "jvm.backend.error.change_blocked_read_only"
var applyReq jvm.ChangeRequest
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
previewSet: true,
preview: jvm.ChangePreview{
Allowed: false,
BlockingReason: providerReason,
},
applyReq: &applyReq,
apply: jvm.ApplyResult{Status: "applied"},
}, nil
})
defer restore()
cfg := connection.ConnectionConfig{
Type: "jvm",
ID: "conn-provider-reason",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}
req := jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{"status": "ready"},
}
previewRes := app.JVMPreviewChange(cfg, req)
if !previewRes.Success {
t.Fatalf("expected blocked preview result, got %+v", previewRes)
}
preview, ok := previewRes.Data.(jvm.ChangePreview)
if !ok {
t.Fatalf("expected preview data, got %#v", previewRes.Data)
}
if preview.BlockingReason != providerReason {
t.Fatalf("expected provider blocking reason to stay raw, got %q", preview.BlockingReason)
}
applyRes := app.JVMApplyChange(cfg, req)
if applyRes.Success {
t.Fatalf("expected provider-blocked apply to fail, got %+v", applyRes)
}
if applyRes.Message != providerReason {
t.Fatalf("expected provider blocking message to stay raw, got %q", applyRes.Message)
}
if applyReq.ResourceID != "" {
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
}
}
func TestJVMPreviewChange(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
readOnly := true
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{}, nil
})
defer restore()
res := app.JVMPreviewChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-readonly",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{"status": "ready"},
})
if !res.Success {
t.Fatalf("expected read-only preview to return blocked preview, got %+v", res)
}
preview, ok := res.Data.(jvm.ChangePreview)
if !ok {
t.Fatalf("expected preview data, got %#v", res.Data)
}
if preview.Allowed {
t.Fatalf("expected preview to be blocked, got %#v", preview)
}
if preview.BlockingReason != "Current connection is read-only, so writes are blocked" {
t.Fatalf("expected localized read-only blocking reason, got %#v", preview)
}
if strings.Contains(preview.BlockingReason, "jvm.backend.") || strings.Contains(preview.BlockingReason, "只读") {
t.Fatalf("expected app boundary to localize blocking reason, got %q", preview.BlockingReason)
}
}
func TestJVMApplyChange(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
app.configDir = t.TempDir()
readOnly := true
var applyReq jvm.ChangeRequest
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
applyReq: &applyReq,
apply: jvm.ApplyResult{Status: "applied"},
}, nil
})
defer restore()
res := app.JVMApplyChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-readonly",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{"status": "ready"},
})
if res.Success {
t.Fatalf("expected read-only apply to fail, got %+v", res)
}
if res.Message != "Current connection is read-only, so writes are blocked" {
t.Fatalf("expected localized read-only blocking reason, got %q", res.Message)
}
if strings.Contains(res.Message, "jvm.backend.") || strings.Contains(res.Message, "只读") {
t.Fatalf("expected app boundary to localize blocking reason, got %q", res.Message)
}
if applyReq.ResourceID != "" {
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
}
}
func TestJVMApplyChangePreviewTokenAllowsConfirmedApply(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
@@ -700,8 +1026,153 @@ func TestJVMApplyChangePreviewTokenAllowsConfirmedApply(t *testing.T) {
}
}
func TestIssueJVMPreviewConfirmationTokenLocalizesPayloadHashError(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
readOnly := false
_, err := app.issueJVMPreviewConfirmationToken(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
Environment: jvm.EnvPROD,
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{
"callback": func() {},
},
}, jvm.ChangePreview{
Allowed: true,
RequiresConfirmation: true,
ConfirmationToken: "preview-token",
Summary: "risky change",
RiskLevel: "high",
})
if err == nil {
t.Fatalf("expected payload hash error")
}
got := err.Error()
want := "Failed to generate JVM preview payload digest: json: unsupported type: func()"
if got != want {
t.Fatalf("expected localized payload hash error %q, got %q", want, got)
}
if strings.Contains(got, "生成 JVM 预览载荷摘要失败") {
t.Fatalf("expected no Chinese payload hash prefix in message %q", got)
}
}
func TestJVMPreviewChangeLocalizesConfirmationTokenFailure(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
readOnly := false
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
previewSet: true,
preview: jvm.ChangePreview{
Allowed: true,
RequiresConfirmation: true,
Summary: "risky change",
RiskLevel: "high",
},
}, nil
})
defer restore()
res := app.JVMPreviewChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
Environment: jvm.EnvPROD,
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{
"callback": func() {},
},
})
if res.Success {
t.Fatalf("expected preview failure, got %+v", res)
}
want := "Failed to generate JVM change confirmation token: json: unsupported type: func()"
if res.Message != want {
t.Fatalf("expected localized confirmation token error %q, got %q", want, res.Message)
}
if strings.Contains(res.Message, "生成 JVM 变更确认令牌失败") {
t.Fatalf("expected no Chinese confirmation token prefix in message %q", res.Message)
}
}
func TestJVMApplyChangeLocalizesConfirmationTokenFailure(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
readOnly := false
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
previewSet: true,
preview: jvm.ChangePreview{
Allowed: true,
RequiresConfirmation: true,
Summary: "risky change",
RiskLevel: "high",
},
}, nil
})
defer restore()
res := app.JVMApplyChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
Environment: jvm.EnvPROD,
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{
"callback": func() {},
},
})
if res.Success {
t.Fatalf("expected apply preview failure, got %+v", res)
}
want := "Failed to generate JVM change confirmation token: json: unsupported type: func()"
if res.Message != want {
t.Fatalf("expected localized confirmation token error %q, got %q", want, res.Message)
}
if strings.Contains(res.Message, "生成 JVM 变更确认令牌失败") {
t.Fatalf("expected no Chinese confirmation token prefix in message %q", res.Message)
}
}
func TestJVMApplyChangeRejectsUnissuedDeterministicConfirmationToken(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
app.configDir = t.TempDir()
readOnly := false
var applyReq jvm.ChangeRequest
@@ -766,11 +1237,86 @@ func TestJVMApplyChangeRejectsUnissuedDeterministicConfirmationToken(t *testing.
if res.Success {
t.Fatalf("expected unissued confirmation token to fail, got %+v", res)
}
assertEnglishJVMConfirmationMessage(t, res.Message, "Confirmation token is invalid. Preview and confirm again.")
if applyReq.ResourceID != "" {
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
}
}
func TestJVMApplyChangeRejectsMismatchedPreviewConfirmationContext(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
app.configDir = t.TempDir()
readOnly := false
applyCalls := 0
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
value: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "stale",
},
},
previewSet: true,
preview: jvm.ChangePreview{
Allowed: true,
RequiresConfirmation: true,
Summary: "risky change",
RiskLevel: "high",
},
applyFn: func(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) {
applyCalls++
return jvm.ApplyResult{Status: "applied"}, nil
},
}, nil
})
defer restore()
cfg := connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
Environment: jvm.EnvPROD,
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}
req := jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{
"status": "ready",
},
}
previewRes := app.JVMPreviewChange(cfg, req)
if !previewRes.Success {
t.Fatalf("expected preview success, got %+v", previewRes)
}
preview, ok := previewRes.Data.(jvm.ChangePreview)
if !ok {
t.Fatalf("expected preview payload, got %#v", previewRes.Data)
}
req.ConfirmationToken = preview.ConfirmationToken
req.ResourceID = "/cache/customers"
res := app.JVMApplyChange(cfg, req)
if res.Success {
t.Fatalf("expected mismatched confirmation context to fail, got %+v", res)
}
assertEnglishJVMConfirmationMessage(t, res.Message, "Confirmation token is invalid. Preview and confirm again.")
if applyCalls != 0 {
t.Fatalf("expected provider ApplyChange not to run, got %d calls", applyCalls)
}
}
func TestJVMApplyChangeRejectsReplayedPreviewConfirmationToken(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
@@ -848,6 +1394,7 @@ func TestJVMApplyChangeRejectsReplayedPreviewConfirmationToken(t *testing.T) {
func TestJVMApplyChangeRejectsExpiredPreviewConfirmationToken(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
app.configDir = t.TempDir()
app.jvmPreviewTokenTTL = time.Nanosecond
readOnly := false
@@ -914,6 +1461,7 @@ func TestJVMApplyChangeRejectsExpiredPreviewConfirmationToken(t *testing.T) {
if res.Success {
t.Fatalf("expected expired confirmation token to fail, got %+v", res)
}
assertEnglishJVMConfirmationMessage(t, res.Message, "Confirmation token expired. Preview and confirm again.")
if applyCalls != 0 {
t.Fatalf("expected provider ApplyChange not to run, got %d calls", applyCalls)
}
@@ -1074,10 +1622,11 @@ func TestJVMApplyChangeNormalizesRequestBeforeProviderAndAudit(t *testing.T) {
}
}
func TestJVMPreviewChangeRejectsModeOutsideAllowedModes(t *testing.T) {
func TestJVMPreviewChangeRejectsDisallowedProviderMode(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
res := app.JVMPreviewChange(connection.ConnectionConfig{
cfg := connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
@@ -1085,18 +1634,33 @@ func TestJVMPreviewChangeRejectsModeOutsideAllowedModes(t *testing.T) {
PreferredMode: "endpoint",
AllowedModes: []string{"endpoint"},
},
}, jvm.ChangeRequest{
}
req := jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
})
}
res := app.JVMPreviewChange(cfg, req)
if res.Success {
t.Fatalf("expected preview request to be rejected, got %+v", res)
}
if !strings.Contains(res.Message, "不允许使用") {
t.Fatalf("expected disallowed mode error, got %+v", res)
want := "Current connection does not allow jmx mode"
if res.Message != want {
t.Fatalf("expected localized disallowed mode error %q, got %+v", want, res)
}
if strings.Contains(res.Message, "不允许使用") || strings.Contains(res.Message, "jvm.backend.") {
t.Fatalf("expected no Chinese or raw key in disallowed mode error, got %q", res.Message)
}
applyRes := app.JVMApplyChange(cfg, req)
if applyRes.Success {
t.Fatalf("expected apply request to be rejected, got %+v", applyRes)
}
if applyRes.Message != want {
t.Fatalf("expected localized apply disallowed mode error %q, got %+v", want, applyRes)
}
}
@@ -1132,12 +1696,14 @@ func TestJVMListAuditRecordsReturnsLatestRecords(t *testing.T) {
func TestJVMApplyChangeFailsClosedWhenInitialAuditWriteFails(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
tempDir := t.TempDir()
blockerPath := filepath.Join(tempDir, "audit-blocker")
if err := os.WriteFile(blockerPath, []byte("blocker"), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
app.configDir = blockerPath
expectedDetail := expectedAuditAppendError(t, blockerPath)
readOnly := false
var applyReq jvm.ChangeRequest
@@ -1181,8 +1747,9 @@ func TestJVMApplyChangeFailsClosedWhenInitialAuditWriteFails(t *testing.T) {
if res.Success {
t.Fatalf("expected fail-closed when initial audit write fails, got %+v", res)
}
if !strings.Contains(res.Message, "审计") {
t.Fatalf("expected audit failure message, got %q", res.Message)
want := "Failed to write audit record, JVM change was blocked: " + expectedDetail
if res.Message != want {
t.Fatalf("expected English audit failure message %q, got %q", want, res.Message)
}
if applyReq.ResourceID != "" {
t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq)
@@ -1246,6 +1813,7 @@ func TestJVMApplyChangeLatestAuditRecordIsTerminal(t *testing.T) {
func TestJVMApplyChangeApplySuccessKeepsSuccessWhenTerminalAuditFails(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
tempDir := t.TempDir()
auditDir := filepath.Join(tempDir, "audit")
if err := os.MkdirAll(auditDir, 0o755); err != nil {
@@ -1302,13 +1870,16 @@ func TestJVMApplyChangeApplySuccessKeepsSuccessWhenTerminalAuditFails(t *testing
if result.Status != "applied" {
t.Fatalf("expected applied status, got %#v", result)
}
if !strings.Contains(result.Message, "终态审计写入失败") {
t.Fatalf("expected terminal audit warning in result message, got %#v", result)
expectedDetail := expectedAuditAppendError(t, auditDir)
want := "ok; Failed to write terminal audit record: " + expectedDetail
if result.Message != want {
t.Fatalf("expected terminal audit warning %q, got %#v", want, result)
}
}
func TestJVMApplyChangeApplyFailureReportsFailedAuditWriteError(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
tempDir := t.TempDir()
auditDir := filepath.Join(tempDir, "audit")
if err := os.MkdirAll(auditDir, 0o755); err != nil {
@@ -1358,11 +1929,10 @@ func TestJVMApplyChangeApplyFailureReportsFailedAuditWriteError(t *testing.T) {
if res.Success {
t.Fatalf("expected failure when apply fails, got %+v", res)
}
if !strings.Contains(res.Message, "provider apply failed") {
t.Fatalf("expected provider failure in message, got %q", res.Message)
}
if !strings.Contains(res.Message, "失败审计写入失败") {
t.Fatalf("expected failed audit write failure in message, got %q", res.Message)
expectedDetail := expectedAuditAppendError(t, auditDir)
want := "provider apply failed; Failed to write failure audit record: " + expectedDetail
if res.Message != want {
t.Fatalf("expected provider failure with failed audit warning %q, got %q", want, res.Message)
}
}
@@ -1412,6 +1982,7 @@ func TestJVMApplyChangeApplyFailureKeepsProviderErrorWhenFailedAuditSucceeds(t *
func TestJVMApplyChangeUsesProviderErrorWhenFailedAuditAlsoFails(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
tempDir := t.TempDir()
auditDir := filepath.Join(tempDir, "audit")
if err := os.MkdirAll(auditDir, 0o755); err != nil {
@@ -1457,16 +2028,16 @@ func TestJVMApplyChangeUsesProviderErrorWhenFailedAuditAlsoFails(t *testing.T) {
if res.Success {
t.Fatalf("expected failure when provider apply fails, got %+v", res)
}
if !strings.HasPrefix(res.Message, "provider apply failed") {
t.Fatalf("expected provider error prefix, got %q", res.Message)
}
if !strings.Contains(res.Message, "失败审计写入失败") {
t.Fatalf("expected failed audit write failure in message, got %q", res.Message)
expectedDetail := expectedAuditAppendError(t, auditDir)
want := "provider apply failed; Failed to write failure audit record: " + expectedDetail
if res.Message != want {
t.Fatalf("expected provider error with English failed audit warning %q, got %q", want, res.Message)
}
}
func TestJVMApplyChangeTerminalAuditWarningAppendsToExistingResultMessage(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
tempDir := t.TempDir()
auditDir := filepath.Join(tempDir, "audit")
if err := os.MkdirAll(auditDir, 0o755); err != nil {
@@ -1509,13 +2080,16 @@ func TestJVMApplyChangeTerminalAuditWarningAppendsToExistingResultMessage(t *tes
if !ok {
t.Fatalf("expected apply result data, got %#v", res.Data)
}
if !strings.Contains(result.Message, "provider message") || !strings.Contains(result.Message, "终态审计写入失败") {
t.Fatalf("expected provider message with terminal audit warning, got %#v", result)
expectedDetail := expectedAuditAppendError(t, auditDir)
want := "provider message; Failed to write terminal audit record: " + expectedDetail
if result.Message != want {
t.Fatalf("expected provider message with English terminal audit warning %q, got %#v", want, result)
}
}
func TestJVMApplyChangeTerminalAuditWarningUsesStandaloneMessageWhenResultMessageEmpty(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
tempDir := t.TempDir()
auditDir := filepath.Join(tempDir, "audit")
if err := os.MkdirAll(auditDir, 0o755); err != nil {
@@ -1558,13 +2132,16 @@ func TestJVMApplyChangeTerminalAuditWarningUsesStandaloneMessageWhenResultMessag
if !ok {
t.Fatalf("expected apply result data, got %#v", res.Data)
}
if !strings.Contains(result.Message, "终态审计写入失败") {
t.Fatalf("expected standalone terminal audit warning, got %#v", result)
expectedDetail := expectedAuditAppendError(t, auditDir)
want := "Failed to write terminal audit record: " + expectedDetail
if result.Message != want {
t.Fatalf("expected standalone English terminal audit warning %q, got %#v", want, result)
}
}
func TestJVMApplyChangeFailedAuditFailureMessageIncludesUnderlyingError(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
tempDir := t.TempDir()
auditDir := filepath.Join(tempDir, "audit")
if err := os.MkdirAll(auditDir, 0o755); err != nil {
@@ -1599,17 +2176,15 @@ func TestJVMApplyChangeFailedAuditFailureMessageIncludesUnderlyingError(t *testi
if res.Success {
t.Fatalf("expected failure when apply fails, got %+v", res)
}
if !strings.Contains(res.Message, "失败审计写入失败") {
t.Fatalf("expected failed audit failure marker, got %q", res.Message)
}
lowerMessage := strings.ToLower(res.Message)
if !strings.Contains(lowerMessage, "not a directory") && !strings.Contains(lowerMessage, "system cannot find the path specified") {
t.Fatalf("expected underlying audit failure detail in message, got %q", res.Message)
expectedDetail := expectedAuditAppendError(t, auditDir)
if !strings.Contains(res.Message, "Failed to write failure audit record: "+expectedDetail) {
t.Fatalf("expected underlying audit failure detail %q in message, got %q", expectedDetail, res.Message)
}
}
func TestJVMApplyChangeFailureMessageSeparatorUsesChineseSemicolon(t *testing.T) {
func TestJVMApplyChangeFailureMessageSeparatorUsesLocalizedEnglishSeparator(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.SetLanguage("en-US")
tempDir := t.TempDir()
auditDir := filepath.Join(tempDir, "audit")
if err := os.MkdirAll(auditDir, 0o755); err != nil {
@@ -1644,8 +2219,11 @@ func TestJVMApplyChangeFailureMessageSeparatorUsesChineseSemicolon(t *testing.T)
if res.Success {
t.Fatalf("expected failure when apply fails, got %+v", res)
}
if !strings.Contains(res.Message, ";失败审计写入失败") {
t.Fatalf("expected chinese semicolon separator in failure message, got %q", res.Message)
if !strings.Contains(res.Message, "; Failed to write failure audit record: ") {
t.Fatalf("expected English separator in failure message, got %q", res.Message)
}
if strings.Contains(res.Message, "") {
t.Fatalf("expected no Chinese semicolon separator in failure message, got %q", res.Message)
}
}

View File

@@ -52,7 +52,13 @@ func (p *AgentProvider) ProbeCapabilities(_ context.Context, cfg connection.Conn
DisplayLabel: "Agent",
Reason: func() string {
if readOnly {
return "当前连接只读"
return changeBlockedReadOnlyKey
}
return ""
}(),
reasonKey: func() string {
if readOnly {
return changeBlockedReadOnlyKey
}
return ""
}(),

View File

@@ -8,6 +8,7 @@ import (
)
const defaultJMXPort = 9010
const disallowedModeKey = "jvm.backend.error.disallowed_mode"
func NormalizeConnectionConfig(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) {
cfg := raw
@@ -51,7 +52,12 @@ func ResolveProviderMode(raw connection.ConnectionConfig, requestedMode string)
selectedMode = cfg.JVM.PreferredMode
}
if !containsMode(cfg.JVM.AllowedModes, selectedMode) {
return connection.ConnectionConfig{}, "", fmt.Errorf("当前连接不允许使用 %q 模式", selectedMode)
return connection.ConnectionConfig{}, "", &LocalizedError{
Key: disallowedModeKey,
Params: map[string]any{
"mode": selectedMode,
},
}
}
cfg.JVM.PreferredMode = selectedMode

View File

@@ -1,6 +1,7 @@
package jvm
import (
"errors"
"testing"
"GoNavi-Wails/internal/connection"
@@ -104,4 +105,14 @@ func TestResolveProviderModeRejectsDisallowedRequestedMode(t *testing.T) {
if err == nil {
t.Fatalf("expected disallowed requested mode to fail")
}
var localized *LocalizedError
if !errors.As(err, &localized) {
t.Fatalf("expected LocalizedError, got %T: %v", err, err)
}
if localized.Key != "jvm.backend.error.disallowed_mode" {
t.Fatalf("expected disallowed mode key, got %q", localized.Key)
}
if localized.Params["mode"] != ModeJMX {
t.Fatalf("expected raw mode param %q, got %#v", ModeJMX, localized.Params)
}
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/url"
@@ -29,6 +28,40 @@ const (
arthasTunnelMaxSessions = 128
)
const (
arthasTunnelMessageCommandCompleted = "jvm.backend.diagnostic.message.arthas_command_completed"
arthasTunnelMessageCommandCanceled = "jvm.backend.diagnostic.message.arthas_command_canceled"
arthasTunnelErrorBaseURLRequired = "jvm.backend.diagnostic.arthas.base_url_required"
arthasTunnelErrorBaseURLInvalid = "jvm.backend.diagnostic.arthas.base_url_invalid"
arthasTunnelErrorTargetIDRequired = "jvm.backend.diagnostic.arthas.target_id_required"
arthasTunnelErrorSchemeUnsupported = "jvm.backend.diagnostic.arthas.scheme_unsupported"
arthasTunnelErrorSessionMissing = "jvm.backend.diagnostic.arthas.session_missing"
arthasTunnelErrorSessionConfigChanged = "jvm.backend.diagnostic.arthas.session_config_changed"
arthasTunnelErrorCommandAlreadyRunning = "jvm.backend.diagnostic.arthas.command_already_running"
arthasTunnelErrorNoRunningCommand = "jvm.backend.diagnostic.arthas.no_running_command"
arthasTunnelErrorCancelCommandMismatch = "jvm.backend.diagnostic.arthas.cancel_command_mismatch"
arthasTunnelErrorConnectionNotReady = "jvm.backend.diagnostic.arthas.connection_not_ready"
arthasTunnelErrorHTTPFailed = "jvm.backend.diagnostic.arthas.http_failed"
arthasTunnelErrorConnectTimeout = "jvm.backend.diagnostic.arthas.connect_timeout"
arthasTunnelErrorConnectCanceled = "jvm.backend.diagnostic.arthas.connect_canceled"
arthasTunnelErrorConnectFailed = "jvm.backend.diagnostic.arthas.connect_failed"
arthasTunnelErrorRequestEncodeFailed = "jvm.backend.diagnostic.arthas.request_encode_failed"
arthasTunnelErrorWriteDeadlineFailed = "jvm.backend.diagnostic.arthas.write_deadline_failed"
arthasTunnelErrorSendTimeout = "jvm.backend.diagnostic.arthas.send_timeout"
arthasTunnelErrorSendCanceled = "jvm.backend.diagnostic.arthas.send_canceled"
arthasTunnelErrorSendFailed = "jvm.backend.diagnostic.arthas.send_failed"
arthasTunnelErrorReadDeadlineFailed = "jvm.backend.diagnostic.arthas.read_deadline_failed"
arthasTunnelErrorReadTimeout = "jvm.backend.diagnostic.arthas.read_timeout"
arthasTunnelErrorReadCanceled = "jvm.backend.diagnostic.arthas.read_canceled"
arthasTunnelErrorReadFailed = "jvm.backend.diagnostic.arthas.read_failed"
arthasTunnelErrorConnectionClosed = "jvm.backend.diagnostic.arthas.connection_closed"
arthasTunnelErrorConnectionClosedCode = "jvm.backend.diagnostic.arthas.connection_closed_code"
arthasTunnelErrorCommandTimeout = "jvm.backend.diagnostic.arthas.command_timeout"
arthasTunnelErrorCommandCanceled = "jvm.backend.diagnostic.arthas.command_canceled"
arthasTunnelErrorTerminalCommandEncodeFailed = "jvm.backend.diagnostic.arthas.terminal_command_encode_failed"
)
var arthasPromptPattern = regexp.MustCompile(`\[arthas@[^\]]+\]\$ `)
type arthasTunnelTTYFrame struct {
@@ -160,8 +193,8 @@ func (t *DiagnosticArthasTunnelTransport) ExecuteCommand(ctx context.Context, cf
defer conn.Close()
if activeCommand.isCancelRequested() {
t.emitChunk(req, "canceled", "Arthas 命令已取消")
return fmt.Errorf("arthas tunnel command canceled")
t.emitChunkWithContentKey(req, "canceled", arthasTunnelMessageCommandCanceled)
return arthasTunnelCommandCanceledError()
}
if err := activeCommand.send(arthasTunnelTTYFrame{
@@ -222,11 +255,11 @@ func (t *DiagnosticArthasTunnelTransport) streamCommandUntilPrompt(
}
if activeCommand.isCancelRequested() || strings.Contains(content, "^C") {
t.emitChunk(req, "canceled", "Arthas 命令已取消")
return fmt.Errorf("arthas tunnel command canceled")
t.emitChunkWithContentKey(req, "canceled", arthasTunnelMessageCommandCanceled)
return arthasTunnelCommandCanceledError()
}
t.emitChunk(req, "completed", "Arthas 命令执行完成")
t.emitChunkWithContentKey(req, "completed", arthasTunnelMessageCommandCompleted)
return nil
}
@@ -242,10 +275,24 @@ func (t *DiagnosticArthasTunnelTransport) streamCommandUntilPrompt(
}
}
func (t *DiagnosticArthasTunnelTransport) emitChunkWithContentKey(req DiagnosticCommandRequest, phase string, contentKey string) {
t.emitChunkWithMetadata(req, phase, "", map[string]any{
"contentKey": contentKey,
})
}
func (t *DiagnosticArthasTunnelTransport) emitChunk(req DiagnosticCommandRequest, phase string, content string) {
t.emitChunkWithMetadata(req, phase, content, nil)
}
func (t *DiagnosticArthasTunnelTransport) emitChunkWithMetadata(req DiagnosticCommandRequest, phase string, content string, metadata map[string]any) {
if t.eventSink == nil {
return
}
if metadata == nil {
metadata = map[string]any{}
}
metadata["transport"] = DiagnosticTransportArthasTunnel
t.eventSink(DiagnosticEventChunk{
SessionID: req.SessionID,
CommandID: req.CommandID,
@@ -253,26 +300,26 @@ func (t *DiagnosticArthasTunnelTransport) emitChunk(req DiagnosticCommandRequest
Phase: phase,
Content: content,
Timestamp: time.Now().UnixMilli(),
Metadata: map[string]any{
"transport": DiagnosticTransportArthasTunnel,
},
Metadata: metadata,
})
}
func newArthasTunnelRuntime(cfg connection.ConnectionConfig) (arthasTunnelRuntime, error) {
baseURLText := strings.TrimSpace(cfg.JVM.Diagnostic.BaseURL)
if baseURLText == "" {
return arthasTunnelRuntime{}, errors.New("Arthas Tunnel 地址不能为空")
return arthasTunnelRuntime{}, arthasTunnelLocalizedError(arthasTunnelErrorBaseURLRequired, nil, nil)
}
baseURL, err := url.Parse(baseURLText)
if err != nil || baseURL.Scheme == "" || baseURL.Host == "" {
return arthasTunnelRuntime{}, fmt.Errorf("Arthas Tunnel 地址格式不正确:%s", baseURLText)
return arthasTunnelRuntime{}, arthasTunnelLocalizedError(arthasTunnelErrorBaseURLInvalid, map[string]any{
"detail": baseURLText,
}, err)
}
targetID := strings.TrimSpace(cfg.JVM.Diagnostic.TargetID)
if targetID == "" {
return arthasTunnelRuntime{}, errors.New("Arthas Tunnel 需要填写目标实例标识targetId / agentId")
return arthasTunnelRuntime{}, arthasTunnelLocalizedError(arthasTunnelErrorTargetIDRequired, nil, nil)
}
scheme := strings.ToLower(strings.TrimSpace(baseURL.Scheme))
@@ -283,7 +330,9 @@ func newArthasTunnelRuntime(cfg connection.ConnectionConfig) (arthasTunnelRuntim
baseURL.Scheme = "wss"
case "ws", "wss":
default:
return arthasTunnelRuntime{}, fmt.Errorf("Arthas Tunnel 仅支持 http/https/ws/wss 地址:%s", baseURL.Scheme)
return arthasTunnelRuntime{}, arthasTunnelLocalizedError(arthasTunnelErrorSchemeUnsupported, map[string]any{
"scheme": baseURL.Scheme,
}, nil)
}
baseURL.Path = resolveArthasTunnelWSPath(baseURL.Path)
@@ -333,9 +382,11 @@ func (r arthasTunnelRuntime) dial(ctx context.Context) (*websocket.Conn, error)
if err != nil {
if resp != nil {
defer resp.Body.Close()
return nil, fmt.Errorf("Arthas Tunnel 连接失败HTTP %s", resp.Status)
return nil, arthasTunnelLocalizedError(arthasTunnelErrorHTTPFailed, map[string]any{
"status": resp.Status,
}, err)
}
return nil, translateArthasTunnelIOError("建立 Arthas Tunnel WebSocket 连接", err, r.timeout)
return nil, translateArthasTunnelIOError("connect", err, r.timeout)
}
return conn, nil
}
@@ -362,14 +413,18 @@ func (r arthasTunnelRuntime) waitForPrompt(ctx context.Context, conn *websocket.
func (r arthasTunnelRuntime) writeFrame(conn *websocket.Conn, frame arthasTunnelTTYFrame) error {
payload, err := json.Marshal(frame)
if err != nil {
return fmt.Errorf("Arthas Tunnel 请求编码失败:%w", err)
return arthasTunnelLocalizedError(arthasTunnelErrorRequestEncodeFailed, map[string]any{
"detail": err.Error(),
}, err)
}
if err := conn.SetWriteDeadline(time.Now().Add(r.timeout)); err != nil {
return fmt.Errorf("Arthas Tunnel 写入超时设置失败:%w", err)
return arthasTunnelLocalizedError(arthasTunnelErrorWriteDeadlineFailed, map[string]any{
"detail": err.Error(),
}, err)
}
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return translateArthasTunnelIOError("向 Arthas Tunnel 发送终端指令", err, r.timeout)
return translateArthasTunnelIOError("send", err, r.timeout)
}
return nil
}
@@ -382,7 +437,9 @@ func (r arthasTunnelRuntime) readTextFrame(ctx context.Context, conn *websocket.
}
if err := conn.SetReadDeadline(readDeadline); err != nil {
return "", fmt.Errorf("Arthas Tunnel 读取超时设置失败:%w", err)
return "", arthasTunnelLocalizedError(arthasTunnelErrorReadDeadlineFailed, map[string]any{
"detail": err.Error(),
}, err)
}
messageType, payload, err := conn.ReadMessage()
@@ -404,59 +461,79 @@ func (r arthasTunnelRuntime) readTextFrame(ctx context.Context, conn *websocket.
}
func translateArthasTunnelIOError(action string, err error, timeout time.Duration) error {
timeoutKey, canceledKey, failedKey := arthasTunnelIOErrorKeys(action)
if errors.Is(err, context.DeadlineExceeded) || isArthasTunnelTimeout(err) {
return fmt.Errorf("%s超时%s 内未收到响应", action, timeout)
return arthasTunnelLocalizedError(timeoutKey, map[string]any{
"timeout": timeout.String(),
}, err)
}
if errors.Is(err, context.Canceled) {
return fmt.Errorf("%s已取消", action)
return arthasTunnelLocalizedError(canceledKey, nil, err)
}
return fmt.Errorf("%s失败%w", action, err)
return arthasTunnelLocalizedError(failedKey, map[string]any{
"detail": err.Error(),
}, err)
}
func translateArthasTunnelReadError(err error, timeout time.Duration) error {
var closeErr *websocket.CloseError
if errors.As(err, &closeErr) {
if strings.TrimSpace(closeErr.Text) != "" {
return fmt.Errorf("Arthas Tunnel 连接已关闭:%s", translateArthasTunnelCloseReason(closeErr.Text))
return arthasTunnelLocalizedError(arthasTunnelErrorConnectionClosed, map[string]any{
"detail": strings.TrimSpace(closeErr.Text),
}, err)
}
return fmt.Errorf("Arthas Tunnel 连接已关闭code=%d", closeErr.Code)
return arthasTunnelLocalizedError(arthasTunnelErrorConnectionClosedCode, map[string]any{
"code": closeErr.Code,
}, err)
}
return translateArthasTunnelIOError("读取 Arthas Tunnel 输出", err, timeout)
return translateArthasTunnelIOError("read", err, timeout)
}
func translateArthasTunnelContextError(err error, timeout time.Duration) error {
if errors.Is(err, context.DeadlineExceeded) {
return fmt.Errorf("Arthas Tunnel 命令执行超时,%s 内未完成", timeout)
return arthasTunnelLocalizedError(arthasTunnelErrorCommandTimeout, map[string]any{
"timeout": timeout.String(),
}, err)
}
if errors.Is(err, context.Canceled) {
return errors.New("Arthas Tunnel 命令已取消")
return arthasTunnelCommandCanceledError()
}
return err
}
func arthasTunnelIOErrorKeys(action string) (timeoutKey string, canceledKey string, failedKey string) {
switch action {
case "connect":
return arthasTunnelErrorConnectTimeout, arthasTunnelErrorConnectCanceled, arthasTunnelErrorConnectFailed
case "send":
return arthasTunnelErrorSendTimeout, arthasTunnelErrorSendCanceled, arthasTunnelErrorSendFailed
default:
return arthasTunnelErrorReadTimeout, arthasTunnelErrorReadCanceled, arthasTunnelErrorReadFailed
}
}
func arthasTunnelCommandCanceledError() error {
return arthasTunnelLocalizedError(
arthasTunnelErrorCommandCanceled,
nil,
errors.New("arthas tunnel command canceled"),
)
}
func arthasTunnelLocalizedError(key string, params map[string]any, cause error) error {
return &LocalizedError{
Key: key,
Params: params,
Cause: cause,
}
}
func isArthasTunnelTimeout(err error) bool {
var netErr net.Error
return errors.As(err, &netErr) && netErr.Timeout()
}
func translateArthasTunnelCloseReason(reason string) string {
trimmed := strings.TrimSpace(reason)
lowerReason := strings.ToLower(trimmed)
switch {
case strings.Contains(lowerReason, "can not find arthas agent by id"):
parts := strings.Split(trimmed, ":")
if len(parts) > 1 {
return "找不到目标实例 " + strings.TrimSpace(parts[len(parts)-1]) + ",请确认 targetId / agentId 是否填写正确,且对应 tunnel client 已在线"
}
return "找不到目标实例,请确认 targetId / agentId 是否填写正确,且对应 tunnel client 已在线"
case strings.Contains(lowerReason, "arthas agent id can not be null"):
return "缺少目标实例标识,请填写 targetId / agentId"
default:
return trimmed
}
}
func newArthasTunnelSessionRegistry() *arthasTunnelSessionRegistry {
return &arthasTunnelSessionRegistry{
sessions: make(map[string]arthasTunnelSessionMeta),
@@ -491,13 +568,13 @@ func (r *arthasTunnelSessionRegistry) beginCommand(sessionID string, commandID s
r.pruneLocked(time.Now().UnixMilli())
meta, ok := r.sessions[sessionID]
if !ok {
return nil, errors.New("诊断会话不存在,请重新创建 Arthas Tunnel 会话")
return nil, arthasTunnelLocalizedError(arthasTunnelErrorSessionMissing, nil, nil)
}
if !meta.matchesConfig(cfg) {
return nil, errors.New("Arthas Tunnel 会话配置已变化,请重新创建诊断会话")
return nil, arthasTunnelLocalizedError(arthasTunnelErrorSessionConfigChanged, nil, nil)
}
if existing := r.active[sessionID]; existing != nil {
return nil, errors.New("当前 Arthas Tunnel 会话已有命令在执行,请先等待完成或取消")
return nil, arthasTunnelLocalizedError(arthasTunnelErrorCommandAlreadyRunning, nil, nil)
}
activeCommand := &arthasTunnelActiveCommand{commandID: commandID}
@@ -567,10 +644,10 @@ func (r *arthasTunnelSessionRegistry) cancelCommand(sessionID string, commandID
r.mu.Unlock()
if activeCommand == nil {
return errors.New("当前 Arthas Tunnel 会话没有正在执行的命令")
return arthasTunnelLocalizedError(arthasTunnelErrorNoRunningCommand, nil, nil)
}
if activeCommand.commandID != commandID {
return errors.New("当前 Arthas Tunnel 会话的活动命令与待取消命令不一致")
return arthasTunnelLocalizedError(arthasTunnelErrorCancelCommandMismatch, nil, nil)
}
return activeCommand.requestCancel()
}
@@ -598,22 +675,28 @@ func (c *arthasTunnelActiveCommand) send(frame arthasTunnelTTYFrame) error {
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return errors.New("Arthas Tunnel 连接尚未建立完成,请稍后重试")
return arthasTunnelLocalizedError(arthasTunnelErrorConnectionNotReady, nil, nil)
}
payload, err := json.Marshal(frame)
if err != nil {
return fmt.Errorf("Arthas Tunnel 终端指令编码失败:%w", err)
return arthasTunnelLocalizedError(arthasTunnelErrorTerminalCommandEncodeFailed, map[string]any{
"detail": err.Error(),
}, err)
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil {
return fmt.Errorf("Arthas Tunnel 写入超时设置失败:%w", err)
return arthasTunnelLocalizedError(arthasTunnelErrorWriteDeadlineFailed, map[string]any{
"detail": err.Error(),
}, err)
}
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return fmt.Errorf("向 Arthas Tunnel 发送终端指令失败:%w", err)
return arthasTunnelLocalizedError(arthasTunnelErrorSendFailed, map[string]any{
"detail": err.Error(),
}, err)
}
return nil
}

View File

@@ -3,6 +3,7 @@ package jvm
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
@@ -138,6 +139,26 @@ func testArthasTunnelConfig(baseURL string) connection.ConnectionConfig {
}
}
func assertArthasTunnelLocalizedError(t *testing.T, err error, key string, params map[string]any) {
t.Helper()
if err == nil {
t.Fatalf("expected localized error %q, got nil", key)
}
var localized *LocalizedError
if !errors.As(err, &localized) || localized == nil {
t.Fatalf("expected LocalizedError %q, got %T: %v", key, err, err)
}
if localized.Key != key {
t.Fatalf("expected localized key %q, got %q with params %#v", key, localized.Key, localized.Params)
}
for name, expected := range params {
if localized.Params[name] != expected {
t.Fatalf("expected param %s=%#v, got %#v in %#v", name, expected, localized.Params[name], localized.Params)
}
}
}
func TestDiagnosticArthasTunnelExecuteCommandStreamsUntilPrompt(t *testing.T) {
commandSeen := make(chan struct{}, 1)
server := newFakeArthasTunnelServer(t, func(conn *websocket.Conn, frame fakeArthasTTYFrame) {
@@ -234,6 +255,12 @@ func TestDiagnosticArthasTunnelExecuteCommandStreamsUntilPrompt(t *testing.T) {
if chunks[len(chunks)-1].Phase != "completed" {
t.Fatalf("expected terminal chunk completed, got %#v", chunks[len(chunks)-1])
}
if chunks[len(chunks)-1].Content != "" {
t.Fatalf("expected completed chunk content to be localized at app boundary, got %#v", chunks[len(chunks)-1])
}
if chunks[len(chunks)-1].Metadata["contentKey"] != "jvm.backend.diagnostic.message.arthas_command_completed" {
t.Fatalf("expected completed contentKey metadata, got %#v", chunks[len(chunks)-1].Metadata)
}
}
func TestDiagnosticArthasTunnelCancelCommandInterruptsActiveCommand(t *testing.T) {
@@ -305,9 +332,7 @@ func TestDiagnosticArthasTunnelCancelCommandInterruptsActiveCommand(t *testing.T
select {
case err := <-errCh:
if err == nil || !strings.Contains(strings.ToLower(err.Error()), "canceled") {
t.Fatalf("expected canceled error, got %v", err)
}
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.command_canceled", nil)
case <-time.After(2 * time.Second):
t.Fatal("expected ExecuteCommand to exit after cancellation")
}
@@ -315,6 +340,16 @@ func TestDiagnosticArthasTunnelCancelCommandInterruptsActiveCommand(t *testing.T
if len(chunks) == 0 {
t.Fatal("expected cancel flow to emit chunks")
}
lastChunk := chunks[len(chunks)-1]
if lastChunk.Phase != "canceled" {
t.Fatalf("expected final chunk phase canceled, got %#v", lastChunk)
}
if lastChunk.Content != "" {
t.Fatalf("expected canceled chunk content to be localized at app boundary, got %#v", lastChunk)
}
if lastChunk.Metadata["contentKey"] != "jvm.backend.diagnostic.message.arthas_command_canceled" {
t.Fatalf("expected canceled contentKey metadata, got %#v", lastChunk.Metadata)
}
}
func TestArthasTunnelActiveCommandAcceptsCancelBeforeConnectionAttach(t *testing.T) {
@@ -358,9 +393,7 @@ func TestDiagnosticArthasTunnelRejectsSessionConfigDrift(t *testing.T) {
if err == nil {
t.Fatal("expected config drift to reject stale Arthas Tunnel session")
}
if !strings.Contains(err.Error(), "会话配置已变化") {
t.Fatalf("expected config drift error, got %v", err)
}
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.session_config_changed", nil)
}
func TestArthasTunnelSessionRegistryPrunesExpiredSessions(t *testing.T) {
@@ -402,9 +435,7 @@ func TestDiagnosticArthasTunnelRequiresTargetID(t *testing.T) {
if err == nil {
t.Fatal("expected missing targetId to be rejected")
}
if !strings.Contains(err.Error(), "target") {
t.Fatalf("expected targetId error, got %v", err)
}
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.target_id_required", nil)
}
func TestDiagnosticArthasTunnelProbeCapabilitiesAndCloseSession(t *testing.T) {
@@ -442,7 +473,93 @@ func TestDiagnosticArthasTunnelProbeCapabilitiesAndCloseSession(t *testing.T) {
if err == nil {
t.Fatal("expected closed synthetic session to reject command execution")
}
if !strings.Contains(err.Error(), "诊断会话不存在") {
t.Fatalf("expected closed-session error, got %v", err)
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.session_missing", nil)
}
func TestDiagnosticArthasTunnelRuntimeReturnsLocalizedValidationErrors(t *testing.T) {
tests := []struct {
name string
cfg connection.ConnectionConfig
key string
params map[string]any
}{
{
name: "base URL required",
cfg: func() connection.ConnectionConfig {
cfg := testArthasTunnelConfig(" ")
return cfg
}(),
key: "jvm.backend.diagnostic.arthas.base_url_required",
},
{
name: "base URL invalid keeps raw detail",
cfg: func() connection.ConnectionConfig {
cfg := testArthasTunnelConfig("://bad-url")
return cfg
}(),
key: "jvm.backend.diagnostic.arthas.base_url_invalid",
params: map[string]any{"detail": "://bad-url"},
},
{
name: "target ID required",
cfg: func() connection.ConnectionConfig {
cfg := testArthasTunnelConfig("http://127.0.0.1:7777")
cfg.JVM.Diagnostic.TargetID = " "
return cfg
}(),
key: "jvm.backend.diagnostic.arthas.target_id_required",
},
{
name: "scheme unsupported keeps raw scheme",
cfg: func() connection.ConnectionConfig {
cfg := testArthasTunnelConfig("ftp://127.0.0.1:7777")
return cfg
}(),
key: "jvm.backend.diagnostic.arthas.scheme_unsupported",
params: map[string]any{"scheme": "ftp"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := newArthasTunnelRuntime(tt.cfg)
assertArthasTunnelLocalizedError(t, err, tt.key, tt.params)
})
}
}
func TestArthasTunnelSessionRegistryReturnsLocalizedStateErrors(t *testing.T) {
registry := newArthasTunnelSessionRegistry()
cfg := testArthasTunnelConfig("http://127.0.0.1:7777")
handle := registry.createSession(cfg)
_, err := registry.beginCommand("missing-session", "cmd-missing", cfg)
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.session_missing", nil)
if _, err := registry.beginCommand(handle.SessionID, "cmd-active", cfg); err != nil {
t.Fatalf("beginCommand returned error: %v", err)
}
_, err = registry.beginCommand(handle.SessionID, "cmd-second", cfg)
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.command_already_running", nil)
err = registry.cancelCommand(handle.SessionID, "cmd-other")
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.cancel_command_mismatch", nil)
registry.finishCommand(handle.SessionID, "cmd-active")
err = registry.cancelCommand(handle.SessionID, "cmd-active")
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.no_running_command", nil)
}
func TestArthasTunnelActiveCommandReturnsLocalizedConnectionNotReady(t *testing.T) {
activeCommand := &arthasTunnelActiveCommand{commandID: "cmd-not-ready"}
err := activeCommand.send(arthasTunnelTTYFrame{Action: "read", Data: "thread -n 1"})
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.connection_not_ready", nil)
}
func TestTranslateArthasTunnelContextErrorReturnsLocalizedCommandCanceled(t *testing.T) {
err := translateArthasTunnelContextError(context.Canceled, time.Second)
assertArthasTunnelLocalizedError(t, err, "jvm.backend.diagnostic.arthas.command_canceled", nil)
}

View File

@@ -9,6 +9,17 @@ import (
const defaultDiagnosticTimeoutSeconds = 15
const (
diagnosticErrorTransportUnsupportedKey = "jvm.backend.diagnostic.error.transport_unsupported"
diagnosticErrorDisabledKey = "jvm.backend.diagnostic.error.disabled"
diagnosticErrorCommandRequiredKey = "jvm.backend.diagnostic.error.command_required"
diagnosticPolicyMultiCommandUnsupportedKey = "jvm.backend.diagnostic.policy.multiline_not_supported"
diagnosticPolicyObserveCommandNotAllowedKey = "jvm.backend.diagnostic.policy.observe_not_allowed"
diagnosticPolicyTraceCommandNotAllowedKey = "jvm.backend.diagnostic.policy.trace_not_allowed"
diagnosticPolicyMutatingNotAllowedKey = "jvm.backend.diagnostic.policy.mutating_not_allowed"
diagnosticPolicyReadOnlyObserveOnlyKey = "jvm.backend.diagnostic.policy.read_only_observe_only"
)
var observeDiagnosticCommands = map[string]struct{}{
"dashboard": {},
"thread": {},
@@ -36,7 +47,12 @@ func NormalizeDiagnosticConfig(cfg connection.ConnectionConfig) (connection.JVMD
normalized := cfg.JVM.Diagnostic
normalized.Transport = normalizeDiagnosticTransport(normalized.Transport)
if normalized.Transport == "" {
return connection.JVMDiagnosticConfig{}, fmt.Errorf("不支持的 JVM 诊断传输模式:%s", strings.TrimSpace(cfg.JVM.Diagnostic.Transport))
return connection.JVMDiagnosticConfig{}, &LocalizedError{
Key: diagnosticErrorTransportUnsupportedKey,
Params: map[string]any{
"transport": cfg.JVM.Diagnostic.Transport,
},
}
}
normalized.BaseURL = strings.TrimSpace(normalized.BaseURL)
@@ -54,7 +70,7 @@ func NormalizeDiagnosticConfig(cfg connection.ConnectionConfig) (connection.JVMD
func ValidateDiagnosticCommandPolicy(cfg connection.JVMDiagnosticConfig, command string) (string, error) {
if !cfg.Enabled {
return "", fmt.Errorf("当前连接未启用 JVM 诊断增强模式")
return "", &LocalizedError{Key: diagnosticErrorDisabledKey}
}
category, normalizedCommand, err := classifyDiagnosticCommand(command)
@@ -65,15 +81,24 @@ func ValidateDiagnosticCommandPolicy(cfg connection.JVMDiagnosticConfig, command
switch category {
case DiagnosticCommandCategoryObserve:
if !cfg.AllowObserveCommands {
return "", fmt.Errorf("当前连接未开放观察类诊断命令:%s", normalizedCommand)
return "", &LocalizedError{
Key: diagnosticPolicyObserveCommandNotAllowedKey,
Params: map[string]any{"command": normalizedCommand},
}
}
case DiagnosticCommandCategoryTrace:
if !cfg.AllowTraceCommands {
return "", fmt.Errorf("当前连接未开放跟踪类诊断命令:%s", normalizedCommand)
return "", &LocalizedError{
Key: diagnosticPolicyTraceCommandNotAllowedKey,
Params: map[string]any{"command": normalizedCommand},
}
}
default:
if !cfg.AllowMutatingCommands {
return "", fmt.Errorf("当前连接未开放高风险诊断命令:%s", normalizedCommand)
return "", &LocalizedError{
Key: diagnosticPolicyMutatingNotAllowedKey,
Params: map[string]any{"command": normalizedCommand},
}
}
}
@@ -94,7 +119,7 @@ func ValidateDiagnosticExecutionPolicy(cfg connection.ConnectionConfig, command
if cfg.JVM.ReadOnly != nil && *cfg.JVM.ReadOnly {
switch category {
case DiagnosticCommandCategoryTrace, DiagnosticCommandCategoryMutating:
return "", fmt.Errorf("当前连接为只读模式,仅允许观察类诊断命令")
return "", &LocalizedError{Key: diagnosticPolicyReadOnlyObserveOnlyKey}
}
}
@@ -104,10 +129,10 @@ func ValidateDiagnosticExecutionPolicy(cfg connection.ConnectionConfig, command
func classifyDiagnosticCommand(command string) (string, string, error) {
normalizedCommand := strings.TrimSpace(command)
if normalizedCommand == "" {
return "", "", fmt.Errorf("诊断命令不能为空")
return "", "", &LocalizedError{Key: diagnosticErrorCommandRequiredKey}
}
if strings.ContainsAny(normalizedCommand, "\r\n") {
return "", "", fmt.Errorf("诊断命令不支持换行或多命令输入")
return "", "", &LocalizedError{Key: diagnosticPolicyMultiCommandUnsupportedKey}
}
fields := strings.Fields(strings.ToLower(normalizedCommand))

View File

@@ -1,6 +1,9 @@
package jvm
import (
"errors"
"reflect"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
@@ -80,3 +83,148 @@ func TestClassifyDiagnosticCommandRejectsMutatingCommandWhenDisabled(t *testing.
t.Fatalf("expected mutating command to be rejected")
}
}
func TestDiagnosticConfigPolicyErrorsReturnLocalizedKeys(t *testing.T) {
enabledAll := connection.JVMDiagnosticConfig{
Enabled: true,
Transport: DiagnosticTransportAgentBridge,
AllowObserveCommands: true,
AllowTraceCommands: true,
AllowMutatingCommands: true,
}
tests := []struct {
name string
run func() error
wantKey string
wantParams map[string]any
}{
{
name: "unsupported transport keeps raw transport parameter",
run: func() error {
_, err := NormalizeDiagnosticConfig(connection.ConnectionConfig{
Type: "jvm",
JVM: connection.JVMConfig{
Diagnostic: connection.JVMDiagnosticConfig{
Transport: " websocket ",
},
},
})
return err
},
wantKey: "jvm.backend.diagnostic.error.transport_unsupported",
wantParams: map[string]any{"transport": " websocket "},
},
{
name: "disabled diagnostic mode",
run: func() error {
_, err := ValidateDiagnosticCommandPolicy(connection.JVMDiagnosticConfig{}, "thread")
return err
},
wantKey: "jvm.backend.diagnostic.error.disabled",
},
{
name: "empty command",
run: func() error {
_, err := ValidateDiagnosticCommandPolicy(enabledAll, " ")
return err
},
wantKey: "jvm.backend.diagnostic.error.command_required",
},
{
name: "multiline command",
run: func() error {
_, err := ValidateDiagnosticCommandPolicy(enabledAll, "thread\nwatch demo.Service call '{params}'")
return err
},
wantKey: "jvm.backend.diagnostic.policy.multiline_not_supported",
},
{
name: "observe command not allowed keeps normalized command parameter",
run: func() error {
_, err := ValidateDiagnosticCommandPolicy(connection.JVMDiagnosticConfig{
Enabled: true,
Transport: DiagnosticTransportAgentBridge,
}, " thread -n 1 ")
return err
},
wantKey: "jvm.backend.diagnostic.policy.observe_not_allowed",
wantParams: map[string]any{"command": "thread -n 1"},
},
{
name: "trace command not allowed keeps normalized command parameter",
run: func() error {
_, err := ValidateDiagnosticCommandPolicy(connection.JVMDiagnosticConfig{
Enabled: true,
Transport: DiagnosticTransportAgentBridge,
AllowObserveCommands: true,
}, "watch demo.Service call '{params}'")
return err
},
wantKey: "jvm.backend.diagnostic.policy.trace_not_allowed",
wantParams: map[string]any{"command": "watch demo.Service call '{params}'"},
},
{
name: "mutating command not allowed keeps normalized command parameter",
run: func() error {
_, err := ValidateDiagnosticCommandPolicy(connection.JVMDiagnosticConfig{
Enabled: true,
Transport: DiagnosticTransportAgentBridge,
AllowObserveCommands: true,
}, "ognl '@java.lang.System@exit(0)'")
return err
},
wantKey: "jvm.backend.diagnostic.policy.mutating_not_allowed",
wantParams: map[string]any{"command": "ognl '@java.lang.System@exit(0)'"},
},
{
name: "read only rejects non observe command",
run: func() error {
readOnly := true
_, err := ValidateDiagnosticExecutionPolicy(connection.ConnectionConfig{
Type: "jvm",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Diagnostic: connection.JVMDiagnosticConfig{
Enabled: true,
Transport: DiagnosticTransportAgentBridge,
AllowObserveCommands: true,
AllowTraceCommands: true,
},
},
}, "trace demo.Service call")
return err
},
wantKey: "jvm.backend.diagnostic.policy.read_only_observe_only",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.run()
if err == nil {
t.Fatalf("expected localized error")
}
var localized *LocalizedError
if !errors.As(err, &localized) {
t.Fatalf("expected LocalizedError, got %T: %v", err, err)
}
if localized.Key != tt.wantKey {
t.Fatalf("localized key=%q, want %q", localized.Key, tt.wantKey)
}
if !reflect.DeepEqual(localized.Params, tt.wantParams) {
t.Fatalf("localized params=%#v, want %#v", localized.Params, tt.wantParams)
}
if containsCJK(err.Error()) {
t.Fatalf("error still exposes legacy Chinese text: %q", err.Error())
}
})
}
}
func containsCJK(value string) bool {
return strings.ContainsFunc(value, func(r rune) bool {
return r >= '\u4e00' && r <= '\u9fff'
})
}

View File

@@ -11,6 +11,38 @@ import (
"GoNavi-Wails/internal/connection"
)
const (
changeBlockedReadOnlyKey = "jvm.backend.error.change_blocked_read_only"
previewConfirmationMissingKey = "jvm.backend.error.preview_confirmation_missing"
confirmationTokenMissingKey = "jvm.backend.error.confirmation_token_missing"
confirmationTokenInvalidKey = "jvm.backend.error.confirmation_token_invalid"
changeConfirmationTokenFailedKey = "jvm.backend.error.change_confirmation_token_failed"
)
// LocalizedError marks JVM package errors that the app boundary can translate.
type LocalizedError struct {
Key string
Params map[string]any
Cause error
}
func (e *LocalizedError) Error() string {
if e == nil {
return ""
}
if e.Cause != nil {
return e.Cause.Error()
}
return e.Key
}
func (e *LocalizedError) Unwrap() error {
if e == nil {
return nil
}
return e.Cause
}
// BuildChangePreview builds a guarded preview for JVM mutations.
// It always produces a local before/after baseline and, when writes are still
// allowed, merges provider preview details on top of that baseline.
@@ -70,7 +102,8 @@ func BuildChangePreview(
if normalized.JVM.ReadOnly != nil && *normalized.JVM.ReadOnly {
preview.Allowed = false
preview.RiskLevel = "high"
preview.BlockingReason = "当前连接为只读,禁止写入"
preview.BlockingReason = changeBlockedReadOnlyKey
preview.blockingReasonKey = changeBlockedReadOnlyKey
}
if normalized.JVM.Environment == EnvPROD {
preview.RequiresConfirmation = true
@@ -200,13 +233,13 @@ func ValidateChangeConfirmation(preview ChangePreview, req ChangeRequest) error
previewToken := strings.TrimSpace(preview.ConfirmationToken)
requestToken := strings.TrimSpace(req.ConfirmationToken)
if previewToken == "" {
return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交")
return &LocalizedError{Key: previewConfirmationMissingKey}
}
if requestToken == "" {
return fmt.Errorf("缺少确认令牌,请先完成预览确认")
return &LocalizedError{Key: confirmationTokenMissingKey}
}
if previewToken != requestToken {
return fmt.Errorf("确认令牌不匹配,请重新预览并确认")
return &LocalizedError{Key: confirmationTokenInvalidKey}
}
return nil
}
@@ -244,7 +277,13 @@ func buildChangeConfirmationToken(cfg connection.ConnectionConfig, req ChangeReq
encoded, err := json.Marshal(input)
if err != nil {
return "", fmt.Errorf("生成 JVM 变更确认令牌失败: %w", err)
return "", &LocalizedError{
Key: changeConfirmationTokenFailedKey,
Params: map[string]any{
"detail": err.Error(),
},
Cause: err,
}
}
sum := sha256.Sum256(encoded)

View File

@@ -65,8 +65,8 @@ func TestPreviewChangeBlocksReadOnlyConnection(t *testing.T) {
if preview.Allowed {
t.Fatalf("expected preview to be blocked, got %#v", preview)
}
if preview.BlockingReason == "" || !strings.Contains(preview.BlockingReason, "只读") {
t.Fatalf("expected readonly blocking reason, got %#v", preview)
if preview.BlockingReason != changeBlockedReadOnlyKey {
t.Fatalf("expected readonly blocking reason key %q, got %#v", changeBlockedReadOnlyKey, preview)
}
if strings.TrimSpace(preview.ConfirmationToken) != "" {
t.Fatalf("expected blocked preview to not include confirmation token, got %#v", preview)
@@ -461,7 +461,7 @@ func TestBuildChangePreviewBlockedByProviderDoesNotGenerateConfirmationToken(t *
}
}
func TestBuildChangePreviewFailsClosedWhenTokenMarshalFails(t *testing.T) {
func TestBuildChangePreviewReturnsLocalizedConfirmationTokenError(t *testing.T) {
readOnly := false
_, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
@@ -492,8 +492,18 @@ func TestBuildChangePreviewFailsClosedWhenTokenMarshalFails(t *testing.T) {
if err == nil {
t.Fatal("expected BuildChangePreview to fail when confirmation token marshal fails")
}
if !strings.Contains(err.Error(), "确认令牌") {
t.Fatalf("expected error to mention confirmation token, got %v", err)
var localized *LocalizedError
if !errors.As(err, &localized) {
t.Fatalf("expected localized confirmation token error, got %T %v", err, err)
}
if localized.Key != changeConfirmationTokenFailedKey {
t.Fatalf("expected key %q, got %q", changeConfirmationTokenFailedKey, localized.Key)
}
if localized.Params["detail"] != "json: unsupported type: func()" {
t.Fatalf("expected raw marshal detail, got %#v", localized.Params)
}
if err.Error() != "json: unsupported type: func()" {
t.Fatalf("expected raw fallback error, got %q", err.Error())
}
}
@@ -503,13 +513,21 @@ func TestValidateChangeConfirmationRejectsMissingOrMismatchedToken(t *testing.T)
RequiresConfirmation: true,
ConfirmationToken: "token-a",
}
if err := ValidateChangeConfirmation(preview, ChangeRequest{}); err == nil {
t.Fatal("expected missing confirmation token to be rejected")
if err := ValidateChangeConfirmation(preview, ChangeRequest{}); localizedErrorKey(err) != confirmationTokenMissingKey {
t.Fatalf("expected missing confirmation token key %q, got %T %v", confirmationTokenMissingKey, err, err)
}
if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-b"}); err == nil {
t.Fatal("expected mismatched confirmation token to be rejected")
if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-b"}); localizedErrorKey(err) != confirmationTokenInvalidKey {
t.Fatalf("expected mismatched confirmation token key %q, got %T %v", confirmationTokenInvalidKey, err, err)
}
if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-a"}); err != nil {
t.Fatalf("expected matching confirmation token to pass, got %v", err)
}
}
func localizedErrorKey(err error) string {
var localized *LocalizedError
if errors.As(err, &localized) && localized != nil {
return localized.Key
}
return ""
}

View File

@@ -53,7 +53,13 @@ func (p *HTTPProvider) ProbeCapabilities(_ context.Context, cfg connection.Conne
DisplayLabel: "Endpoint",
Reason: func() string {
if readOnly {
return "当前连接只读"
return changeBlockedReadOnlyKey
}
return ""
}(),
reasonKey: func() string {
if readOnly {
return changeBlockedReadOnlyKey
}
return ""
}(),

View File

@@ -233,24 +233,49 @@ func TestHTTPProviderPreviewChangeAndApplySendJSONBody(t *testing.T) {
}
}
func TestHTTPProviderProbeCapabilitiesReflectsReadOnlyConnection(t *testing.T) {
provider := NewHTTPProvider()
cfg := newHTTPProviderTestConfig("https://orders.internal/manage/jvm", 3)
func TestProvidersProbeCapabilitiesUseReadOnlyReasonKey(t *testing.T) {
readOnly := true
cfg.JVM.ReadOnly = &readOnly
cases := []struct {
name string
provider Provider
cfg connection.ConnectionConfig
}{
{
name: "jmx",
provider: NewJMXProvider(),
cfg: newJMXProviderTestConfig(),
},
{
name: "endpoint",
provider: NewHTTPProvider(),
cfg: newHTTPProviderTestConfig("https://orders.internal/manage/jvm", 3),
},
{
name: "agent",
provider: NewAgentProvider(),
cfg: newAgentProviderTestConfig("https://orders.internal/agent", 3),
},
}
caps, err := provider.ProbeCapabilities(context.Background(), cfg)
if err != nil {
t.Fatalf("ProbeCapabilities returned error: %v", err)
}
if len(caps) != 1 {
t.Fatalf("expected one capability, got %#v", caps)
}
if caps[0].CanWrite {
t.Fatalf("expected endpoint capability to be readonly, got %#v", caps[0])
}
if caps[0].Reason != "当前连接只读" {
t.Fatalf("expected readonly reason, got %#v", caps[0])
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := tc.cfg
cfg.JVM.ReadOnly = &readOnly
caps, err := tc.provider.ProbeCapabilities(context.Background(), cfg)
if err != nil {
t.Fatalf("ProbeCapabilities returned error: %v", err)
}
if len(caps) != 1 {
t.Fatalf("expected one capability, got %#v", caps)
}
if caps[0].CanWrite {
t.Fatalf("expected capability to be readonly, got %#v", caps[0])
}
if caps[0].Reason != changeBlockedReadOnlyKey {
t.Fatalf("expected readonly reason key %q, got %#v", changeBlockedReadOnlyKey, caps[0])
}
})
}
}

View File

@@ -39,7 +39,13 @@ func (p *JMXProvider) ProbeCapabilities(ctx context.Context, cfg connection.Conn
DisplayLabel: "JMX",
Reason: func() string {
if readOnly {
return "当前连接只读"
return changeBlockedReadOnlyKey
}
return ""
}(),
reasonKey: func() string {
if readOnly {
return changeBlockedReadOnlyKey
}
return ""
}(),

View File

@@ -3,6 +3,7 @@ package jvm
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"time"
@@ -17,6 +18,16 @@ const (
maxMonitoringSampleFailures = 3
)
const (
monitoringSnapshotUnsupportedKey = "jvm.backend.monitoring.error.snapshot_unsupported"
monitoringSessionNotFoundKey = "jvm.backend.monitoring.error.session_not_found"
)
const (
monitoringWarningMarkerPrefix = "__gonavi_i18n__:"
monitoringSampleAutoStoppedWarningKey = "jvm.backend.monitoring.warning.sample_auto_stopped"
)
var monitoringProviderFactory = NewProvider
type monitoringManager struct {
@@ -94,7 +105,12 @@ func (m *monitoringManager) Start(ctx context.Context, raw connection.Connection
monitoringProvider, ok := provider.(MonitoringCapableProvider)
if !ok {
return MonitoringSessionSnapshot{}, fmt.Errorf("%s provider does not implement monitoring snapshot yet", ModeDisplayLabel(providerMode))
return MonitoringSessionSnapshot{}, &LocalizedError{
Key: monitoringSnapshotUnsupportedKey,
Params: map[string]any{
"provider": ModeDisplayLabel(providerMode),
},
}
}
generation := session.reset(connectionID, providerMode)
@@ -118,7 +134,7 @@ func (m *monitoringManager) Stop(connectionID string, providerMode string) error
session, ok := m.sessions[m.sessionKey(connectionID, providerMode)]
m.mu.Unlock()
if !ok {
return fmt.Errorf("monitoring session not found for %s %s", connectionID, providerMode)
return monitoringSessionNotFoundError(connectionID, providerMode)
}
session.stop()
@@ -130,11 +146,21 @@ func (m *monitoringManager) GetHistory(connectionID string, providerMode string)
session, ok := m.sessions[m.sessionKey(connectionID, providerMode)]
m.mu.Unlock()
if !ok {
return MonitoringSessionSnapshot{}, fmt.Errorf("monitoring session not found for %s %s", connectionID, providerMode)
return MonitoringSessionSnapshot{}, monitoringSessionNotFoundError(connectionID, providerMode)
}
return session.snapshot(), nil
}
func monitoringSessionNotFoundError(connectionID string, providerMode string) error {
return &LocalizedError{
Key: monitoringSessionNotFoundKey,
Params: map[string]any{
"connectionId": connectionID,
"providerMode": providerMode,
},
}
}
func (s *monitoringSession) appendPoint(point JVMMonitoringPoint) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -164,7 +190,7 @@ func (m *monitoringManager) runSampler(ctx context.Context, provider MonitoringC
consecutiveFailures++
session.appendWarning(err.Error())
if consecutiveFailures >= maxMonitoringSampleFailures {
session.appendWarning(fmt.Sprintf("监控采样连续失败 %d 次,已自动停止本次监控会话", consecutiveFailures))
session.appendWarning(FormatMonitoringSampleAutoStoppedWarning(consecutiveFailures))
session.markStopped(generation)
return
}
@@ -242,6 +268,30 @@ func (s *monitoringSession) applySnapshot(snapshot JVMMonitoringSnapshot, genera
return true
}
func FormatMonitoringSampleAutoStoppedWarning(count int) string {
return fmt.Sprintf("%s%s:count=%d", monitoringWarningMarkerPrefix, monitoringSampleAutoStoppedWarningKey, count)
}
func ParseMonitoringProviderWarning(warning string) (string, map[string]any, bool) {
payload, ok := strings.CutPrefix(strings.TrimSpace(warning), monitoringWarningMarkerPrefix)
if !ok {
return "", nil, false
}
key, rawParams, ok := strings.Cut(payload, ":")
if !ok || key != monitoringSampleAutoStoppedWarningKey {
return "", nil, false
}
name, value, ok := strings.Cut(rawParams, "=")
if !ok || name != "count" {
return "", nil, false
}
count, err := strconv.Atoi(value)
if err != nil {
return "", nil, false
}
return key, map[string]any{"count": count}, true
}
func (s *monitoringSession) appendWarning(warning string) {
s.mu.Lock()
defer s.mu.Unlock()

View File

@@ -3,6 +3,7 @@ package jvm
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
@@ -22,6 +23,8 @@ type blockingMonitoringProvider struct {
once sync.Once
}
type fakeProviderWithoutMonitoring struct{}
func (f fakeMonitoringProvider) Mode() string { return ModeJMX }
func (f fakeMonitoringProvider) TestConnection(context.Context, connection.ConnectionConfig) error {
return nil
@@ -53,6 +56,26 @@ func (p *blockingMonitoringProvider) GetMonitoringSnapshot(context.Context, conn
return p.snapshot, p.snapshotErr
}
func (f fakeProviderWithoutMonitoring) Mode() string { return ModeJMX }
func (f fakeProviderWithoutMonitoring) TestConnection(context.Context, connection.ConnectionConfig) error {
return nil
}
func (f fakeProviderWithoutMonitoring) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]Capability, error) {
return nil, nil
}
func (f fakeProviderWithoutMonitoring) ListResources(context.Context, connection.ConnectionConfig, string) ([]ResourceSummary, error) {
return nil, nil
}
func (f fakeProviderWithoutMonitoring) GetValue(context.Context, connection.ConnectionConfig, string) (ValueSnapshot, error) {
return ValueSnapshot{}, nil
}
func (f fakeProviderWithoutMonitoring) PreviewChange(context.Context, connection.ConnectionConfig, ChangeRequest) (ChangePreview, error) {
return ChangePreview{}, nil
}
func (f fakeProviderWithoutMonitoring) ApplyChange(context.Context, connection.ConnectionConfig, ChangeRequest) (ApplyResult, error) {
return ApplyResult{}, nil
}
func swapMonitoringProviderFactory(factory func(mode string) (Provider, error)) func() {
prev := monitoringProviderFactory
monitoringProviderFactory = factory
@@ -216,6 +239,60 @@ func TestMonitoringManagerStopMarksSessionStopped(t *testing.T) {
}
}
func TestMonitoringManagerReturnsLocalizedErrorsForFixedMonitoringFailures(t *testing.T) {
manager := newMonitoringManagerForTest(5)
_, err := manager.GetHistory("conn-missing", ModeJMX)
assertMonitoringLocalizedError(t, err, "jvm.backend.monitoring.error.session_not_found", map[string]any{
"connectionId": "conn-missing",
"providerMode": ModeJMX,
})
err = manager.Stop("conn-missing", ModeAgent)
assertMonitoringLocalizedError(t, err, "jvm.backend.monitoring.error.session_not_found", map[string]any{
"connectionId": "conn-missing",
"providerMode": ModeAgent,
})
restore := swapMonitoringProviderFactory(func(mode string) (Provider, error) {
return fakeProviderWithoutMonitoring{}, nil
})
defer restore()
_, err = manager.Start(context.Background(), connection.ConnectionConfig{
ID: "conn-monitor",
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, "")
assertMonitoringLocalizedError(t, err, "jvm.backend.monitoring.error.snapshot_unsupported", map[string]any{
"provider": "JMX",
})
}
func assertMonitoringLocalizedError(t *testing.T, err error, key string, params map[string]any) {
t.Helper()
if err == nil {
t.Fatalf("expected localized error %q, got nil", key)
}
var localized *LocalizedError
if !errors.As(err, &localized) {
t.Fatalf("expected LocalizedError %q, got %T: %v", key, err, err)
}
if localized.Key != key {
t.Fatalf("expected localized key %q, got %q", key, localized.Key)
}
for name, expected := range params {
if localized.Params[name] != expected {
t.Fatalf("expected param %s=%#v, got %#v in %#v", name, expected, localized.Params[name], localized.Params)
}
}
}
func TestMonitoringSessionIgnoresStaleStopFromPreviousSampler(t *testing.T) {
session := &monitoringSession{}
@@ -331,6 +408,15 @@ func TestMonitoringSamplerStopsAfterConsecutiveFailures(t *testing.T) {
if len(snapshot.ProviderWarnings) == 0 {
t.Fatalf("expected provider warnings to explain sampling failure")
}
expectedFinalWarning := "__gonavi_i18n__:jvm.backend.monitoring.warning.sample_auto_stopped:count=3"
if snapshot.ProviderWarnings[len(snapshot.ProviderWarnings)-1] != expectedFinalWarning {
t.Fatalf("expected final warning to be structured, got %#v", snapshot.ProviderWarnings)
}
for _, warning := range snapshot.ProviderWarnings {
if strings.Contains(warning, "监控采样连续失败") {
t.Fatalf("expected no localized Chinese warning in manager snapshot, got %#v", snapshot.ProviderWarnings)
}
}
return
case <-deadline:
t.Fatal("sampler did not stop after consecutive failures")

View File

@@ -13,9 +13,14 @@ type Capability struct {
CanWrite bool `json:"canWrite"`
CanPreview bool `json:"canPreview"`
Reason string `json:"reason,omitempty"`
reasonKey string
DisplayLabel string `json:"displayLabel"`
}
func (c Capability) ReasonLocalizationKey() string {
return c.reasonKey
}
type ResourceSummary struct {
ID string `json:"id"`
ParentID string `json:"parentId,omitempty"`
@@ -69,16 +74,21 @@ type ChangeRequest struct {
}
type ChangePreview struct {
Allowed bool `json:"allowed"`
RequiresConfirmation bool `json:"requiresConfirmation,omitempty"`
ConfirmationToken string `json:"confirmationToken,omitempty"`
Summary string `json:"summary"`
RiskLevel string `json:"riskLevel"`
BlockingReason string `json:"blockingReason,omitempty"`
Allowed bool `json:"allowed"`
RequiresConfirmation bool `json:"requiresConfirmation,omitempty"`
ConfirmationToken string `json:"confirmationToken,omitempty"`
Summary string `json:"summary"`
RiskLevel string `json:"riskLevel"`
BlockingReason string `json:"blockingReason,omitempty"`
blockingReasonKey string
Before ValueSnapshot `json:"before"`
After ValueSnapshot `json:"after"`
}
func (p ChangePreview) BlockingReasonLocalizationKey() string {
return p.blockingReasonKey
}
type ApplyResult struct {
Status string `json:"status"`
Message string `json:"message,omitempty"`