From 83bcb1659790af4b1679194f2d70db2f76638d5a Mon Sep 17 00:00:00 2001 From: viettranx Date: Thu, 2 Apr 2026 09:45:41 +0700 Subject: [PATCH] refactor(channels): extract health model and modularize UI components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-merge cleanup for #634 channel health diagnostics: Backend: - Extract health types, ClassifyChannelError, mergeChannelHealth, buildRemediation into internal/channels/health.go - Fix case-true anti-pattern → default in Slack config - Remove duplicate connection-refused case in ClassifyChannelError - Remove unused _ bool param from BaseChannel.setHealth - Fix snapshot.Enabled == false → !snapshot.Enabled Web UI: - Extract channel status utilities to channels-status-utils.ts - Extract ChannelDiagnosticsCard from channel-detail-page.tsx - Extract ChannelAttentionPanel from system-health-card.tsx Desktop UI: - Extract shared getChannelStatusDisplay() to utils/channel-status.ts - Add explicit stopped state case for visual consistency with web UI - Add missing vi/zh locale keys for new health status states --- cmd/gateway_channels_setup.go | 2 +- internal/channels/channel.go | 353 +---------------- internal/channels/health.go | 348 +++++++++++++++++ .../settings/channels/ChannelCard.tsx | 34 +- .../settings/channels/ChannelDetailPanel.tsx | 34 +- .../src/i18n/locales/vi/channels.json | 7 +- .../src/i18n/locales/zh/channels.json | 7 +- .../frontend/src/utils/channel-status.ts | 50 +++ .../channel-detail/channel-detail-page.tsx | 106 +---- .../channel-diagnostics-card.tsx | 123 ++++++ .../pages/channels/channels-status-utils.ts | 342 ++++++++++++++++ .../pages/channels/channels-status-view.tsx | 365 +----------------- .../overview/channel-attention-panel.tsx | 77 ++++ .../src/pages/overview/system-health-card.tsx | 63 +-- 14 files changed, 999 insertions(+), 912 deletions(-) create mode 100644 internal/channels/health.go create mode 100644 ui/desktop/frontend/src/utils/channel-status.ts create mode 100644 ui/web/src/pages/channels/channel-detail/channel-diagnostics-card.tsx create mode 100644 ui/web/src/pages/channels/channels-status-utils.ts create mode 100644 ui/web/src/pages/overview/channel-attention-panel.tsx diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index eb2a47af..64cc6cd6 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -108,7 +108,7 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms recordMissingConfig(channels.TypeSlack, "Set channels.slack.bot_token in config.") case cfg.Channels.Slack.AppToken == "": recordMissingConfig(channels.TypeSlack, "Set channels.slack.app_token in config.") - case true: + default: sl, err := slackchannel.New(cfg.Channels.Slack, msgBus, nil, nil) if err != nil { channelMgr.RecordFailure(channels.TypeSlack, "", err) diff --git a/internal/channels/channel.go b/internal/channels/channel.go index b7eec16e..d0aec0b8 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -67,345 +67,6 @@ const ( TypeZaloPersonal = "zalo_personal" ) -// ChannelHealthState captures the current runtime state of a channel instance. -type ChannelHealthState string - -const ( - ChannelHealthStateRegistered ChannelHealthState = "registered" - ChannelHealthStateStarting ChannelHealthState = "starting" - ChannelHealthStateHealthy ChannelHealthState = "healthy" - ChannelHealthStateDegraded ChannelHealthState = "degraded" - ChannelHealthStateFailed ChannelHealthState = "failed" - ChannelHealthStateStopped ChannelHealthState = "stopped" -) - -// ChannelFailureKind classifies the dominant cause of the current failure state. -type ChannelFailureKind string - -const ( - ChannelFailureKindAuth ChannelFailureKind = "auth" - ChannelFailureKindConfig ChannelFailureKind = "config" - ChannelFailureKindNetwork ChannelFailureKind = "network" - ChannelFailureKindUnknown ChannelFailureKind = "unknown" -) - -// ChannelRemediationCode identifies the next operator step suggested for a channel incident. -type ChannelRemediationCode string - -const ( - ChannelRemediationCodeReauth ChannelRemediationCode = "reauth" - ChannelRemediationCodeOpenCredentials ChannelRemediationCode = "open_credentials" - ChannelRemediationCodeOpenAdvanced ChannelRemediationCode = "open_advanced" - ChannelRemediationCodeCheckNetwork ChannelRemediationCode = "check_network" -) - -// ChannelRemediationTarget tells the UI which existing surface can help resolve the issue. -type ChannelRemediationTarget string - -const ( - ChannelRemediationTargetCredentials ChannelRemediationTarget = "credentials" - ChannelRemediationTargetAdvanced ChannelRemediationTarget = "advanced" - ChannelRemediationTargetReauth ChannelRemediationTarget = "reauth" - ChannelRemediationTargetDetails ChannelRemediationTarget = "details" -) - -// ChannelRemediation contains a coarse, additive operator hint for the current incident. -type ChannelRemediation struct { - Code ChannelRemediationCode `json:"code"` - Headline string `json:"headline"` - Hint string `json:"hint,omitempty"` - Target ChannelRemediationTarget `json:"target,omitempty"` -} - -// ChannelHealth is the shared runtime health snapshot exposed via channels.status. -type ChannelHealth struct { - ChannelType string `json:"-"` - Enabled bool `json:"enabled"` - Running bool `json:"running"` - State ChannelHealthState `json:"state"` - Summary string `json:"summary,omitempty"` - Detail string `json:"detail,omitempty"` - FailureKind ChannelFailureKind `json:"failure_kind,omitempty"` - Retryable bool `json:"retryable"` - CheckedAt time.Time `json:"checked_at,omitempty"` - FailureCount int `json:"failure_count,omitempty"` - ConsecutiveFailures int `json:"consecutive_failures,omitempty"` - FirstFailedAt time.Time `json:"first_failed_at,omitempty"` - LastFailedAt time.Time `json:"last_failed_at,omitempty"` - LastHealthyAt time.Time `json:"last_healthy_at,omitempty"` - Remediation *ChannelRemediation `json:"remediation,omitempty"` -} - -// ChannelErrorInfo contains shared error classification output for operators. -type ChannelErrorInfo struct { - Summary string - Detail string - Kind ChannelFailureKind - Retryable bool -} - -// ClassifyChannelError maps common channel startup/runtime failures into operator-facing buckets. -func ClassifyChannelError(err error) ChannelErrorInfo { - if err == nil { - return ChannelErrorInfo{ - Summary: "Channel failed", - Detail: "GoClaw could not determine the latest channel error.", - Kind: ChannelFailureKindUnknown, - Retryable: true, - } - } - - detail := err.Error() - msg := strings.ToLower(detail) - - switch { - case strings.Contains(msg, "401") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden"): - return ChannelErrorInfo{ - Summary: "Authentication failed", - Detail: "The upstream service rejected the configured credentials or session.", - Kind: ChannelFailureKindAuth, - Retryable: false, - } - case strings.Contains(msg, "invalid proxy"): - return ChannelErrorInfo{ - Summary: "Configuration is invalid", - Detail: "Configured proxy URL is invalid.", - Kind: ChannelFailureKindConfig, - Retryable: false, - } - case strings.Contains(msg, "agent ") && strings.Contains(msg, " not found for channel"): - return ChannelErrorInfo{ - Summary: "Configuration is invalid", - Detail: "The linked agent for this channel could not be found.", - Kind: ChannelFailureKindConfig, - Retryable: false, - } - case strings.Contains(msg, "token is required"), - strings.Contains(msg, "missing credentials"), - strings.Contains(msg, "decode "), - strings.Contains(msg, "not found for channel"), - strings.Contains(msg, "required"): - safeDetail := "A required channel setting is missing or invalid." - switch { - case strings.Contains(msg, "token is required"), strings.Contains(msg, "missing credentials"): - safeDetail = "Required channel credentials are missing or incomplete." - case strings.Contains(msg, "decode "): - safeDetail = "Saved channel configuration could not be parsed." - } - return ChannelErrorInfo{ - Summary: "Configuration is invalid", - Detail: safeDetail, - Kind: ChannelFailureKindConfig, - Retryable: false, - } - case strings.Contains(msg, "timeout"), - strings.Contains(msg, "i/o timeout"), - strings.Contains(msg, "deadline exceeded"), - strings.Contains(msg, "context deadline exceeded"): - return ChannelErrorInfo{ - Summary: "Network error", - Detail: "Timed out while reaching the upstream service.", - Kind: ChannelFailureKindNetwork, - Retryable: true, - } - case strings.Contains(msg, "connection refused"): - return ChannelErrorInfo{ - Summary: "Network error", - Detail: "The upstream service refused the connection attempt.", - Kind: ChannelFailureKindNetwork, - Retryable: true, - } - case strings.Contains(msg, "no such host"): - return ChannelErrorInfo{ - Summary: "Network error", - Detail: "GoClaw could not resolve the upstream host.", - Kind: ChannelFailureKindNetwork, - Retryable: true, - } - case strings.Contains(msg, "connection reset"), - strings.Contains(msg, "eof"): - return ChannelErrorInfo{ - Summary: "Network error", - Detail: "The upstream service closed the connection unexpectedly.", - Kind: ChannelFailureKindNetwork, - Retryable: true, - } - case strings.Contains(msg, "dial tcp"), - strings.Contains(msg, "connection refused"), - strings.Contains(msg, "tcp "): - return ChannelErrorInfo{ - Summary: "Network error", - Detail: "GoClaw could not open a network connection to the upstream service.", - Kind: ChannelFailureKindNetwork, - Retryable: true, - } - default: - return ChannelErrorInfo{ - Summary: "Channel failed", - Detail: "An unexpected channel error occurred. Review server logs for the full error.", - Kind: ChannelFailureKindUnknown, - Retryable: true, - } - } -} - -// NewChannelHealth builds a shared runtime snapshot with a current timestamp. -func NewChannelHealth(state ChannelHealthState, summary, detail string, kind ChannelFailureKind, retryable bool) ChannelHealth { - return NewChannelHealthForType("", state, summary, detail, kind, retryable) -} - -// NewChannelHealthForType builds a shared runtime snapshot for a specific channel type. -func NewChannelHealthForType(channelType string, state ChannelHealthState, summary, detail string, kind ChannelFailureKind, retryable bool) ChannelHealth { - return ChannelHealth{ - ChannelType: channelType, - Enabled: true, - Running: state == ChannelHealthStateHealthy || state == ChannelHealthStateDegraded, - State: state, - Summary: summary, - Detail: detail, - FailureKind: kind, - Retryable: retryable, - CheckedAt: time.Now().UTC(), - } -} - -// NewFailedChannelHealth builds a failed snapshot from a classified error. -func NewFailedChannelHealth(summary string, err error) ChannelHealth { - return NewFailedChannelHealthForType("", summary, err) -} - -// NewFailedChannelHealthForType builds a failed snapshot from a classified error for one channel type. -func NewFailedChannelHealthForType(channelType, summary string, err error) ChannelHealth { - info := ClassifyChannelError(err) - if summary == "" { - summary = info.Summary - } - return NewChannelHealthForType(channelType, ChannelHealthStateFailed, summary, info.Detail, info.Kind, info.Retryable) -} - -func isFailureState(state ChannelHealthState) bool { - return state == ChannelHealthStateFailed || state == ChannelHealthStateDegraded -} - -func mergeChannelHealth(previous, snapshot ChannelHealth) ChannelHealth { - if snapshot.CheckedAt.IsZero() { - snapshot.CheckedAt = time.Now().UTC() - } - if snapshot.Enabled == false { - snapshot.Enabled = true - } - if snapshot.ChannelType == "" { - snapshot.ChannelType = previous.ChannelType - } - - if isFailureState(snapshot.State) { - if snapshot.FailureCount == 0 { - snapshot.FailureCount = previous.FailureCount + 1 - } - if snapshot.ConsecutiveFailures == 0 { - snapshot.ConsecutiveFailures = previous.ConsecutiveFailures + 1 - } - if snapshot.FirstFailedAt.IsZero() { - if previous.FirstFailedAt.IsZero() || !isFailureState(previous.State) { - snapshot.FirstFailedAt = snapshot.CheckedAt - } else { - snapshot.FirstFailedAt = previous.FirstFailedAt - } - } - if snapshot.LastFailedAt.IsZero() { - snapshot.LastFailedAt = snapshot.CheckedAt - } - if snapshot.LastHealthyAt.IsZero() { - snapshot.LastHealthyAt = previous.LastHealthyAt - } - } else { - if snapshot.FailureCount == 0 { - snapshot.FailureCount = previous.FailureCount - } - snapshot.ConsecutiveFailures = 0 - snapshot.FirstFailedAt = time.Time{} - if snapshot.LastFailedAt.IsZero() { - snapshot.LastFailedAt = previous.LastFailedAt - } - if snapshot.State == ChannelHealthStateHealthy { - snapshot.LastHealthyAt = snapshot.CheckedAt - } else if snapshot.LastHealthyAt.IsZero() { - snapshot.LastHealthyAt = previous.LastHealthyAt - } - } - - snapshot.Remediation = buildChannelRemediation(snapshot) - return snapshot -} - -func buildChannelRemediation(snapshot ChannelHealth) *ChannelRemediation { - if !isFailureState(snapshot.State) { - return nil - } - - text := strings.ToLower(snapshot.Summary + " " + snapshot.Detail) - - switch snapshot.FailureKind { - case ChannelFailureKindAuth: - if snapshot.ChannelType == TypeZaloPersonal { - return &ChannelRemediation{ - Code: ChannelRemediationCodeReauth, - Headline: "Reconnect the channel session", - Hint: "Open the sign-in flow again to restore the current session.", - Target: ChannelRemediationTargetReauth, - } - } - return &ChannelRemediation{ - Code: ChannelRemediationCodeOpenCredentials, - Headline: "Review channel credentials", - Hint: "Open credentials and confirm the current token or secret is still valid.", - Target: ChannelRemediationTargetCredentials, - } - case ChannelFailureKindConfig: - if strings.Contains(text, "credential") || - strings.Contains(text, "token") || - strings.Contains(text, "secret") || - strings.Contains(text, "app_id") || - strings.Contains(text, "app id") || - strings.Contains(text, "required") { - return &ChannelRemediation{ - Code: ChannelRemediationCodeOpenCredentials, - Headline: "Complete required credentials", - Hint: "Open credentials and fill the missing or invalid values for this channel.", - Target: ChannelRemediationTargetCredentials, - } - } - return &ChannelRemediation{ - Code: ChannelRemediationCodeOpenAdvanced, - Headline: "Review channel settings", - Hint: "Open advanced settings and correct the invalid channel configuration.", - Target: ChannelRemediationTargetAdvanced, - } - case ChannelFailureKindNetwork: - return &ChannelRemediation{ - Code: ChannelRemediationCodeCheckNetwork, - Headline: "Check upstream reachability", - Hint: "Verify the upstream service is reachable from GoClaw, then inspect proxy or API server settings if you use them.", - Target: ChannelRemediationTargetDetails, - } - default: - if snapshot.Retryable { - return &ChannelRemediation{ - Code: ChannelRemediationCodeCheckNetwork, - Headline: "Inspect the latest failure", - Hint: "Open the channel details and review the latest runtime error before retrying.", - Target: ChannelRemediationTargetDetails, - } - } - return &ChannelRemediation{ - Code: ChannelRemediationCodeOpenAdvanced, - Headline: "Review channel settings", - Hint: "Open channel settings and inspect the latest error detail.", - Target: ChannelRemediationTargetAdvanced, - } - } -} - // Channel defines the interface that all channel implementations must satisfy. type Channel interface { // Name returns the channel instance name (e.g., "telegram", "discord", "slack"). @@ -617,7 +278,7 @@ func (c *BaseChannel) MarkRegistered(summary string) { if summary == "" { summary = "Configured" } - c.setHealth(NewChannelHealth(ChannelHealthStateRegistered, summary, "", ChannelFailureKindUnknown, false), false) + c.setHealth(NewChannelHealth(ChannelHealthStateRegistered, summary, "", ChannelFailureKindUnknown, false)) } // MarkStarting records that the channel is in startup validation / connection setup. @@ -625,7 +286,7 @@ func (c *BaseChannel) MarkStarting(summary string) { if summary == "" { summary = "Starting" } - c.setHealth(NewChannelHealth(ChannelHealthStateStarting, summary, "", ChannelFailureKindUnknown, true), false) + c.setHealth(NewChannelHealth(ChannelHealthStateStarting, summary, "", ChannelFailureKindUnknown, true)) } // MarkHealthy records a healthy connected state. @@ -633,7 +294,7 @@ func (c *BaseChannel) MarkHealthy(summary string) { if summary == "" { summary = "Connected" } - c.setHealth(NewChannelHealth(ChannelHealthStateHealthy, summary, "", ChannelFailureKindUnknown, false), false) + c.setHealth(NewChannelHealth(ChannelHealthStateHealthy, summary, "", ChannelFailureKindUnknown, false)) } // MarkDegraded records a non-fatal warning state. @@ -641,7 +302,7 @@ func (c *BaseChannel) MarkDegraded(summary, detail string, kind ChannelFailureKi if summary == "" { summary = "Running with warnings" } - c.setHealth(NewChannelHealth(ChannelHealthStateDegraded, summary, detail, kind, retryable), true) + c.setHealth(NewChannelHealth(ChannelHealthStateDegraded, summary, detail, kind, retryable)) } // MarkFailed records a startup or runtime failure. @@ -649,7 +310,7 @@ func (c *BaseChannel) MarkFailed(summary, detail string, kind ChannelFailureKind if summary == "" { summary = "Channel failed" } - c.setHealth(NewChannelHealth(ChannelHealthStateFailed, summary, detail, kind, retryable), true) + c.setHealth(NewChannelHealth(ChannelHealthStateFailed, summary, detail, kind, retryable)) } // MarkStopped records a cleanly stopped state. @@ -657,10 +318,10 @@ func (c *BaseChannel) MarkStopped(summary string) { if summary == "" { summary = "Stopped" } - c.setHealth(NewChannelHealth(ChannelHealthStateStopped, summary, "", ChannelFailureKindUnknown, false), false) + c.setHealth(NewChannelHealth(ChannelHealthStateStopped, summary, "", ChannelFailureKindUnknown, false)) } -func (c *BaseChannel) setHealth(snapshot ChannelHealth, _ bool) { +func (c *BaseChannel) setHealth(snapshot ChannelHealth) { c.stateMu.Lock() defer c.stateMu.Unlock() diff --git a/internal/channels/health.go b/internal/channels/health.go new file mode 100644 index 00000000..f3436bd9 --- /dev/null +++ b/internal/channels/health.go @@ -0,0 +1,348 @@ +package channels + +import ( + "strings" + "time" +) + +// ChannelHealthState captures the current runtime state of a channel instance. +type ChannelHealthState string + +const ( + ChannelHealthStateRegistered ChannelHealthState = "registered" + ChannelHealthStateStarting ChannelHealthState = "starting" + ChannelHealthStateHealthy ChannelHealthState = "healthy" + ChannelHealthStateDegraded ChannelHealthState = "degraded" + ChannelHealthStateFailed ChannelHealthState = "failed" + ChannelHealthStateStopped ChannelHealthState = "stopped" +) + +// ChannelFailureKind classifies the dominant cause of the current failure state. +type ChannelFailureKind string + +const ( + ChannelFailureKindAuth ChannelFailureKind = "auth" + ChannelFailureKindConfig ChannelFailureKind = "config" + ChannelFailureKindNetwork ChannelFailureKind = "network" + ChannelFailureKindUnknown ChannelFailureKind = "unknown" +) + +// ChannelRemediationCode identifies the next operator step suggested for a channel incident. +type ChannelRemediationCode string + +const ( + ChannelRemediationCodeReauth ChannelRemediationCode = "reauth" + ChannelRemediationCodeOpenCredentials ChannelRemediationCode = "open_credentials" + ChannelRemediationCodeOpenAdvanced ChannelRemediationCode = "open_advanced" + ChannelRemediationCodeCheckNetwork ChannelRemediationCode = "check_network" +) + +// ChannelRemediationTarget tells the UI which existing surface can help resolve the issue. +type ChannelRemediationTarget string + +const ( + ChannelRemediationTargetCredentials ChannelRemediationTarget = "credentials" + ChannelRemediationTargetAdvanced ChannelRemediationTarget = "advanced" + ChannelRemediationTargetReauth ChannelRemediationTarget = "reauth" + ChannelRemediationTargetDetails ChannelRemediationTarget = "details" +) + +// ChannelRemediation contains a coarse, additive operator hint for the current incident. +type ChannelRemediation struct { + Code ChannelRemediationCode `json:"code"` + Headline string `json:"headline"` + Hint string `json:"hint,omitempty"` + Target ChannelRemediationTarget `json:"target,omitempty"` +} + +// ChannelHealth is the shared runtime health snapshot exposed via channels.status. +type ChannelHealth struct { + ChannelType string `json:"-"` + Enabled bool `json:"enabled"` + Running bool `json:"running"` + State ChannelHealthState `json:"state"` + Summary string `json:"summary,omitempty"` + Detail string `json:"detail,omitempty"` + FailureKind ChannelFailureKind `json:"failure_kind,omitempty"` + Retryable bool `json:"retryable"` + CheckedAt time.Time `json:"checked_at,omitempty"` + FailureCount int `json:"failure_count,omitempty"` + ConsecutiveFailures int `json:"consecutive_failures,omitempty"` + FirstFailedAt time.Time `json:"first_failed_at,omitempty"` + LastFailedAt time.Time `json:"last_failed_at,omitempty"` + LastHealthyAt time.Time `json:"last_healthy_at,omitempty"` + Remediation *ChannelRemediation `json:"remediation,omitempty"` +} + +// ChannelErrorInfo contains shared error classification output for operators. +type ChannelErrorInfo struct { + Summary string + Detail string + Kind ChannelFailureKind + Retryable bool +} + +// ClassifyChannelError maps common channel startup/runtime failures into operator-facing buckets. +// This is a best-effort classification based on error message patterns from upstream libraries +// (telego, discordgo, etc.). If upstream changes error format, the default case returns +// "unknown + retryable", so misclassification degrades gracefully to generic guidance. +// TODO: Use errors.As() where upstream libraries expose typed errors. +func ClassifyChannelError(err error) ChannelErrorInfo { + if err == nil { + return ChannelErrorInfo{ + Summary: "Channel failed", + Detail: "GoClaw could not determine the latest channel error.", + Kind: ChannelFailureKindUnknown, + Retryable: true, + } + } + + detail := err.Error() + msg := strings.ToLower(detail) + + switch { + case strings.Contains(msg, "401") || strings.Contains(msg, "unauthorized") || strings.Contains(msg, "forbidden"): + return ChannelErrorInfo{ + Summary: "Authentication failed", + Detail: "The upstream service rejected the configured credentials or session.", + Kind: ChannelFailureKindAuth, + Retryable: false, + } + case strings.Contains(msg, "invalid proxy"): + return ChannelErrorInfo{ + Summary: "Configuration is invalid", + Detail: "Configured proxy URL is invalid.", + Kind: ChannelFailureKindConfig, + Retryable: false, + } + case strings.Contains(msg, "agent ") && strings.Contains(msg, " not found for channel"): + return ChannelErrorInfo{ + Summary: "Configuration is invalid", + Detail: "The linked agent for this channel could not be found.", + Kind: ChannelFailureKindConfig, + Retryable: false, + } + case strings.Contains(msg, "token is required"), + strings.Contains(msg, "missing credentials"), + strings.Contains(msg, "decode "), + strings.Contains(msg, "not found for channel"), + strings.Contains(msg, "required"): + safeDetail := "A required channel setting is missing or invalid." + switch { + case strings.Contains(msg, "token is required"), strings.Contains(msg, "missing credentials"): + safeDetail = "Required channel credentials are missing or incomplete." + case strings.Contains(msg, "decode "): + safeDetail = "Saved channel configuration could not be parsed." + } + return ChannelErrorInfo{ + Summary: "Configuration is invalid", + Detail: safeDetail, + Kind: ChannelFailureKindConfig, + Retryable: false, + } + case strings.Contains(msg, "timeout"), + strings.Contains(msg, "i/o timeout"), + strings.Contains(msg, "deadline exceeded"), + strings.Contains(msg, "context deadline exceeded"): + return ChannelErrorInfo{ + Summary: "Network error", + Detail: "Timed out while reaching the upstream service.", + Kind: ChannelFailureKindNetwork, + Retryable: true, + } + case strings.Contains(msg, "connection refused"): + return ChannelErrorInfo{ + Summary: "Network error", + Detail: "The upstream service refused the connection attempt.", + Kind: ChannelFailureKindNetwork, + Retryable: true, + } + case strings.Contains(msg, "no such host"): + return ChannelErrorInfo{ + Summary: "Network error", + Detail: "GoClaw could not resolve the upstream host.", + Kind: ChannelFailureKindNetwork, + Retryable: true, + } + case strings.Contains(msg, "connection reset"), + strings.Contains(msg, "eof"): + return ChannelErrorInfo{ + Summary: "Network error", + Detail: "The upstream service closed the connection unexpectedly.", + Kind: ChannelFailureKindNetwork, + Retryable: true, + } + case strings.Contains(msg, "dial tcp"), + strings.Contains(msg, "tcp "): + return ChannelErrorInfo{ + Summary: "Network error", + Detail: "GoClaw could not open a network connection to the upstream service.", + Kind: ChannelFailureKindNetwork, + Retryable: true, + } + default: + return ChannelErrorInfo{ + Summary: "Channel failed", + Detail: "An unexpected channel error occurred. Review server logs for the full error.", + Kind: ChannelFailureKindUnknown, + Retryable: true, + } + } +} + +// NewChannelHealth builds a shared runtime snapshot with a current timestamp. +func NewChannelHealth(state ChannelHealthState, summary, detail string, kind ChannelFailureKind, retryable bool) ChannelHealth { + return NewChannelHealthForType("", state, summary, detail, kind, retryable) +} + +// NewChannelHealthForType builds a shared runtime snapshot for a specific channel type. +func NewChannelHealthForType(channelType string, state ChannelHealthState, summary, detail string, kind ChannelFailureKind, retryable bool) ChannelHealth { + return ChannelHealth{ + ChannelType: channelType, + Enabled: true, + Running: state == ChannelHealthStateHealthy || state == ChannelHealthStateDegraded, + State: state, + Summary: summary, + Detail: detail, + FailureKind: kind, + Retryable: retryable, + CheckedAt: time.Now().UTC(), + } +} + +// NewFailedChannelHealth builds a failed snapshot from a classified error. +func NewFailedChannelHealth(summary string, err error) ChannelHealth { + return NewFailedChannelHealthForType("", summary, err) +} + +// NewFailedChannelHealthForType builds a failed snapshot from a classified error for one channel type. +func NewFailedChannelHealthForType(channelType, summary string, err error) ChannelHealth { + info := ClassifyChannelError(err) + if summary == "" { + summary = info.Summary + } + return NewChannelHealthForType(channelType, ChannelHealthStateFailed, summary, info.Detail, info.Kind, info.Retryable) +} + +func isFailureState(state ChannelHealthState) bool { + return state == ChannelHealthStateFailed || state == ChannelHealthStateDegraded +} + +func mergeChannelHealth(previous, snapshot ChannelHealth) ChannelHealth { + if snapshot.CheckedAt.IsZero() { + snapshot.CheckedAt = time.Now().UTC() + } + if !snapshot.Enabled { + snapshot.Enabled = true + } + if snapshot.ChannelType == "" { + snapshot.ChannelType = previous.ChannelType + } + + if isFailureState(snapshot.State) { + if snapshot.FailureCount == 0 { + snapshot.FailureCount = previous.FailureCount + 1 + } + if snapshot.ConsecutiveFailures == 0 { + snapshot.ConsecutiveFailures = previous.ConsecutiveFailures + 1 + } + if snapshot.FirstFailedAt.IsZero() { + if previous.FirstFailedAt.IsZero() || !isFailureState(previous.State) { + snapshot.FirstFailedAt = snapshot.CheckedAt + } else { + snapshot.FirstFailedAt = previous.FirstFailedAt + } + } + if snapshot.LastFailedAt.IsZero() { + snapshot.LastFailedAt = snapshot.CheckedAt + } + if snapshot.LastHealthyAt.IsZero() { + snapshot.LastHealthyAt = previous.LastHealthyAt + } + } else { + if snapshot.FailureCount == 0 { + snapshot.FailureCount = previous.FailureCount + } + snapshot.ConsecutiveFailures = 0 + snapshot.FirstFailedAt = time.Time{} + if snapshot.LastFailedAt.IsZero() { + snapshot.LastFailedAt = previous.LastFailedAt + } + if snapshot.State == ChannelHealthStateHealthy { + snapshot.LastHealthyAt = snapshot.CheckedAt + } else if snapshot.LastHealthyAt.IsZero() { + snapshot.LastHealthyAt = previous.LastHealthyAt + } + } + + snapshot.Remediation = buildChannelRemediation(snapshot) + return snapshot +} + +func buildChannelRemediation(snapshot ChannelHealth) *ChannelRemediation { + if !isFailureState(snapshot.State) { + return nil + } + + text := strings.ToLower(snapshot.Summary + " " + snapshot.Detail) + + switch snapshot.FailureKind { + case ChannelFailureKindAuth: + if snapshot.ChannelType == TypeZaloPersonal { + return &ChannelRemediation{ + Code: ChannelRemediationCodeReauth, + Headline: "Reconnect the channel session", + Hint: "Open the sign-in flow again to restore the current session.", + Target: ChannelRemediationTargetReauth, + } + } + return &ChannelRemediation{ + Code: ChannelRemediationCodeOpenCredentials, + Headline: "Review channel credentials", + Hint: "Open credentials and confirm the current token or secret is still valid.", + Target: ChannelRemediationTargetCredentials, + } + case ChannelFailureKindConfig: + if strings.Contains(text, "credential") || + strings.Contains(text, "token") || + strings.Contains(text, "secret") || + strings.Contains(text, "app_id") || + strings.Contains(text, "app id") || + strings.Contains(text, "required") { + return &ChannelRemediation{ + Code: ChannelRemediationCodeOpenCredentials, + Headline: "Complete required credentials", + Hint: "Open credentials and fill the missing or invalid values for this channel.", + Target: ChannelRemediationTargetCredentials, + } + } + return &ChannelRemediation{ + Code: ChannelRemediationCodeOpenAdvanced, + Headline: "Review channel settings", + Hint: "Open advanced settings and correct the invalid channel configuration.", + Target: ChannelRemediationTargetAdvanced, + } + case ChannelFailureKindNetwork: + return &ChannelRemediation{ + Code: ChannelRemediationCodeCheckNetwork, + Headline: "Check upstream reachability", + Hint: "Verify the upstream service is reachable from GoClaw, then inspect proxy or API server settings if you use them.", + Target: ChannelRemediationTargetDetails, + } + default: + if snapshot.Retryable { + return &ChannelRemediation{ + Code: ChannelRemediationCodeCheckNetwork, + Headline: "Inspect the latest failure", + Hint: "Open the channel details and review the latest runtime error before retrying.", + Target: ChannelRemediationTargetDetails, + } + } + return &ChannelRemediation{ + Code: ChannelRemediationCodeOpenAdvanced, + Headline: "Review channel settings", + Hint: "Open channel settings and inspect the latest error detail.", + Target: ChannelRemediationTargetAdvanced, + } + } +} diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx index 8b6f8177..db064fa3 100644 --- a/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx @@ -4,6 +4,7 @@ import type { ChannelInstanceData, ChannelStatus, } from "../../../types/channel"; +import { getChannelStatusDisplay } from "../../../utils/channel-status"; interface ChannelCardProps { instance: ChannelInstanceData; @@ -39,38 +40,7 @@ export function ChannelCard({ const { t } = useTranslation("channels"); const isTelegram = instance.channel_type === "telegram"; - // Status dot color - let dotColor = "bg-gray-400"; - let statusText = t("status.disabled"); - if (instance.enabled) { - switch (status?.state) { - case "healthy": - dotColor = "bg-emerald-500"; - statusText = t("status.running"); - break; - case "degraded": - dotColor = "bg-amber-500"; - statusText = t("status.degraded", { defaultValue: "Degraded" }); - break; - case "starting": - dotColor = "bg-sky-500"; - statusText = t("status.starting", { defaultValue: "Starting" }); - break; - case "registered": - dotColor = "bg-slate-400"; - statusText = t("status.registered", { defaultValue: "Configured" }); - break; - case "failed": - dotColor = "bg-red-500"; - statusText = t("status.failed", { defaultValue: "Failed" }); - break; - default: - dotColor = status?.running ? "bg-emerald-500" : "bg-amber-500"; - statusText = status?.running - ? t("status.running") - : t("status.stopped"); - } - } + const { dotColor, statusText } = getChannelStatusDisplay(instance.enabled, status, t); const summaryHint = instance.enabled && status?.summary && status.state !== "healthy" ? status.summary diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx index 1ef7f1aa..f47db42d 100644 --- a/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx @@ -7,6 +7,7 @@ import { ChannelCredentialsTab } from "./ChannelCredentialsTab"; import { ChannelManagersTab } from "./ChannelManagersTab"; import { ChannelAdvancedTab } from "./ChannelAdvancedTab"; import type { ChannelStatus } from "../../../types/channel"; +import { getChannelStatusDisplay } from "../../../utils/channel-status"; interface ChannelDetailPanelProps { instanceId: string; @@ -59,38 +60,7 @@ export function ChannelDetailPanel({ ); } - // Status - let dotColor = "bg-gray-400"; - let statusText = t("status.disabled"); - if (instance.enabled) { - switch (status?.state) { - case "healthy": - dotColor = "bg-emerald-500"; - statusText = t("status.running"); - break; - case "degraded": - dotColor = "bg-amber-500"; - statusText = t("status.degraded", { defaultValue: "Degraded" }); - break; - case "starting": - dotColor = "bg-sky-500"; - statusText = t("status.starting", { defaultValue: "Starting" }); - break; - case "registered": - dotColor = "bg-slate-400"; - statusText = t("status.registered", { defaultValue: "Configured" }); - break; - case "failed": - dotColor = "bg-red-500"; - statusText = t("status.failed", { defaultValue: "Failed" }); - break; - default: - dotColor = status?.running ? "bg-emerald-500" : "bg-amber-500"; - statusText = status?.running - ? t("status.running") - : t("status.stopped"); - } - } + const { dotColor, statusText } = getChannelStatusDisplay(instance.enabled, status, t); const agentName = (() => { const a = agents.find((a) => a.id === instance.agent_id); diff --git a/ui/desktop/frontend/src/i18n/locales/vi/channels.json b/ui/desktop/frontend/src/i18n/locales/vi/channels.json index 9ae5ed98..6ef3d173 100644 --- a/ui/desktop/frontend/src/i18n/locales/vi/channels.json +++ b/ui/desktop/frontend/src/i18n/locales/vi/channels.json @@ -8,7 +8,11 @@ "status": { "running": "Đang chạy", "stopped": "Đã dừng", - "disabled": "Tắt" + "disabled": "Tắt", + "degraded": "Suy giảm", + "starting": "Đang khởi động", + "registered": "Đã cấu hình", + "failed": "Thất bại" }, "channelTypes": { "telegram": "Telegram", @@ -38,6 +42,7 @@ }, "detail": { "back": "Quay lại", + "lastChecked": "Kiểm tra gần nhất: {{value}}", "tabs": { "general": "Chung", "credentials": "Xác thực", diff --git a/ui/desktop/frontend/src/i18n/locales/zh/channels.json b/ui/desktop/frontend/src/i18n/locales/zh/channels.json index 034d97f9..f1c29d93 100644 --- a/ui/desktop/frontend/src/i18n/locales/zh/channels.json +++ b/ui/desktop/frontend/src/i18n/locales/zh/channels.json @@ -8,7 +8,11 @@ "status": { "running": "运行中", "stopped": "已停止", - "disabled": "已禁用" + "disabled": "已禁用", + "degraded": "降级", + "starting": "启动中", + "registered": "已配置", + "failed": "失败" }, "channelTypes": { "telegram": "Telegram", @@ -38,6 +42,7 @@ }, "detail": { "back": "返回", + "lastChecked": "最近检查:{{value}}", "tabs": { "general": "常规", "credentials": "凭据", diff --git a/ui/desktop/frontend/src/utils/channel-status.ts b/ui/desktop/frontend/src/utils/channel-status.ts new file mode 100644 index 00000000..20c50ede --- /dev/null +++ b/ui/desktop/frontend/src/utils/channel-status.ts @@ -0,0 +1,50 @@ +import type { ChannelStatus } from "../types/channel"; + +interface ChannelStatusDisplay { + dotColor: string; + statusText: string; +} + +export function getChannelStatusDisplay( + enabled: boolean, + status: ChannelStatus | null, + t: (key: string, options?: Record) => string, +): ChannelStatusDisplay { + // Default: disabled + let dotColor = "bg-gray-400"; + let statusText = t("status.disabled"); + + if (enabled) { + switch (status?.state) { + case "healthy": + dotColor = "bg-emerald-500"; + statusText = t("status.running"); + break; + case "degraded": + dotColor = "bg-amber-500"; + statusText = t("status.degraded", { defaultValue: "Degraded" }); + break; + case "starting": + dotColor = "bg-sky-500"; + statusText = t("status.starting", { defaultValue: "Starting" }); + break; + case "registered": + dotColor = "bg-slate-400"; + statusText = t("status.registered", { defaultValue: "Configured" }); + break; + case "failed": + dotColor = "bg-red-500"; + statusText = t("status.failed", { defaultValue: "Failed" }); + break; + case "stopped": + dotColor = "bg-gray-400"; + statusText = t("status.stopped"); + break; + default: + dotColor = status?.running ? "bg-emerald-500" : "bg-gray-400"; + statusText = status?.running ? t("status.running") : t("status.stopped"); + } + } + + return { dotColor, statusText }; +} diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx index 9c2c019b..5a62ef26 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx @@ -1,10 +1,8 @@ import { useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router"; -import { AlertTriangle, CheckCircle2 } from "lucide-react"; +import { CheckCircle2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; import { useChannelDetail } from "../hooks/use-channel-detail"; import { useAgents } from "@/pages/agents/hooks/use-agents"; import { ChannelHeader } from "./channel-header"; @@ -13,6 +11,7 @@ import { ChannelCredentialsTab } from "./channel-credentials-tab"; import { ChannelGroupsTab } from "./channel-groups-tab"; import { ChannelManagersTab } from "./channel-managers-tab"; import { ChannelAdvancedDialog } from "./channel-advanced-dialog"; +import { ChannelDiagnosticsCard } from "./channel-diagnostics-card"; import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog"; import { DetailPageSkeleton } from "@/components/shared/loading-skeleton"; import { useChannels } from "../hooks/use-channels"; @@ -223,98 +222,15 @@ export function ChannelDetailPage({
{showDiagnosticsCard && status && ( -
-
-
-
- - - {t("detail.whatHappened", { - defaultValue: "What happened", - })} - -
-

- {status.summary || statusMeta.label} -

-

- {status.remediation?.headline || diagnosticsHint} -

- -
-

- {t("detail.recommendedAction", { - defaultValue: "Recommended action", - })} -

-

- {remediation?.label || - t("actions.inspect", { defaultValue: "Inspect issue" })} -

-

- {diagnosticsHint} -

- {remediation && remediation.target !== "details" && ( - - )} -
- - {status.detail && ( -
- - {t("detail.technicalDetail", { - defaultValue: "Technical detail", - })} - -

- {status.detail} -

-
- )} -
- -
-

- {t("detail.timeline.title", { defaultValue: "Timeline" })} -

-
- {timelineItems.length > 0 ? ( - timelineItems.map((item) => ( -
- - {item.label} - - - {item.value} - -
- )) - ) : ( -
- {checkedLabel || - t("detail.timeline.noData", { - defaultValue: "No recent channel checks recorded yet.", - })} -
- )} -
-
-
-
+ )} {neutralHealthNote && ( diff --git a/ui/web/src/pages/channels/channel-detail/channel-diagnostics-card.tsx b/ui/web/src/pages/channels/channel-detail/channel-diagnostics-card.tsx new file mode 100644 index 00000000..904f21c1 --- /dev/null +++ b/ui/web/src/pages/channels/channel-detail/channel-diagnostics-card.tsx @@ -0,0 +1,123 @@ +import { useTranslation } from "react-i18next"; +import { AlertTriangle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { ChannelRuntimeStatus } from "@/types/channel"; +import type { ChannelStatusMeta, ChannelRemediationMeta } from "../channels-status-utils"; + +interface ChannelDiagnosticsCardProps { + status: ChannelRuntimeStatus; + statusMeta: ChannelStatusMeta; + remediation: ChannelRemediationMeta | null; + checkedLabel: string | null; + diagnosticsHint: string; + timelineItems: Array<{ label: string; value: string }>; + onRemediationAction: () => void; +} + +export function ChannelDiagnosticsCard({ + status, + statusMeta, + remediation, + checkedLabel, + diagnosticsHint, + timelineItems, + onRemediationAction, +}: ChannelDiagnosticsCardProps) { + const { t } = useTranslation("channels"); + + return ( +
+
+
+
+ + + {t("detail.whatHappened", { + defaultValue: "What happened", + })} + +
+

+ {status.summary || statusMeta.label} +

+

+ {status.remediation?.headline || diagnosticsHint} +

+ +
+

+ {t("detail.recommendedAction", { + defaultValue: "Recommended action", + })} +

+

+ {remediation?.label || + t("actions.inspect", { defaultValue: "Inspect issue" })} +

+

+ {diagnosticsHint} +

+ {remediation && remediation.target !== "details" && ( + + )} +
+ + {status.detail && ( +
+ + {t("detail.technicalDetail", { + defaultValue: "Technical detail", + })} + +

+ {status.detail} +

+
+ )} +
+ +
+

+ {t("detail.timeline.title", { defaultValue: "Timeline" })} +

+
+ {timelineItems.length > 0 ? ( + timelineItems.map((item) => ( +
+ + {item.label} + + + {item.value} + +
+ )) + ) : ( +
+ {checkedLabel || + t("detail.timeline.noData", { + defaultValue: "No recent channel checks recorded yet.", + })} +
+ )} +
+
+
+
+ ); +} diff --git a/ui/web/src/pages/channels/channels-status-utils.ts b/ui/web/src/pages/channels/channels-status-utils.ts new file mode 100644 index 00000000..12e9f958 --- /dev/null +++ b/ui/web/src/pages/channels/channels-status-utils.ts @@ -0,0 +1,342 @@ +import type { TFunction } from "i18next"; +import i18next from "i18next"; +import type { + ChannelInstanceData, + ChannelRuntimeStatus, +} from "@/types/channel"; + +export type ChannelStatus = ChannelRuntimeStatus; + +export const channelTypeLabels: Record = { + telegram: "Telegram", + discord: "Discord", + slack: "Slack", + feishu: "Feishu / Lark", + zalo_oa: "Zalo OA", + zalo_personal: "Zalo Personal", + whatsapp: "WhatsApp", +}; + +export type BadgeVariant = + | "secondary" + | "success" + | "warning" + | "info" + | "destructive"; + +export interface ChannelStatusMeta { + dotClass: string; + badgeVariant: BadgeVariant; + label: string; + surfaceClass: string; + priority: number; + attention: boolean; +} + +export interface ChannelRemediationMeta { + target: "credentials" | "advanced" | "reauth" | "details"; + label: string; + headline: string; + hint?: string; +} + +export function translateChannelText(key: string, defaultValue: string) { + const value = i18next.t(`channels:${key}`, { defaultValue }); + return typeof value === "string" && value.length > 0 ? value : defaultValue; +} + +export function getRelativeUnit(diffSeconds: number) { + const abs = Math.abs(diffSeconds); + if (abs < 60) return { value: Math.round(diffSeconds), unit: "second" as const }; + if (abs < 3600) return { value: Math.round(diffSeconds / 60), unit: "minute" as const }; + if (abs < 86400) return { value: Math.round(diffSeconds / 3600), unit: "hour" as const }; + if (abs < 604800) return { value: Math.round(diffSeconds / 86400), unit: "day" as const }; + return { value: Math.round(diffSeconds / 604800), unit: "week" as const }; +} + +export function formatRelativeTime(value?: string) { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + if (date.getUTCFullYear() <= 1) return null; + const diffSeconds = (date.getTime() - Date.now()) / 1000; + const { value: relativeValue, unit } = getRelativeUnit(diffSeconds); + const language = i18next.resolvedLanguage || i18next.language || undefined; + return new Intl.RelativeTimeFormat(language, { numeric: "auto" }).format( + relativeValue, + unit, + ); +} + +export function getChannelStatusFallback( + instance: Pick< + ChannelInstanceData, + "enabled" | "has_credentials" | "channel_type" + >, +): ChannelRuntimeStatus | null { + if (!instance.enabled || instance.has_credentials) { + return null; + } + + if (instance.channel_type === "zalo_personal") { + return { + enabled: true, + running: false, + state: "failed", + summary: translateChannelText( + "fallback.authRequiredSummary", + "Authentication required", + ), + detail: translateChannelText( + "fallback.authRequiredDetail", + "Channel instance is enabled but requires sign-in before it can connect.", + ), + failure_kind: "auth", + retryable: false, + remediation: { + code: "reauth", + headline: translateChannelText( + "fallback.authRequiredHeadline", + "Reconnect the channel session", + ), + hint: translateChannelText( + "fallback.authRequiredHint", + "Authenticate this channel again to restore the current session.", + ), + target: "reauth", + }, + }; + } + + return { + enabled: true, + running: false, + state: "failed", + summary: translateChannelText( + "fallback.missingCredentialsSummary", + "Missing credentials", + ), + detail: translateChannelText( + "fallback.missingCredentialsDetail", + "Channel instance is enabled but required credentials are incomplete.", + ), + failure_kind: "config", + retryable: false, + remediation: { + code: "open_credentials", + headline: translateChannelText( + "fallback.missingCredentialsHeadline", + "Complete required credentials", + ), + hint: translateChannelText( + "fallback.missingCredentialsHint", + "Open credentials and fill the missing or invalid values for this channel.", + ), + target: "credentials", + }, + }; +} + +export function getRenderableChannelStatus( + status: ChannelRuntimeStatus | null | undefined, + instance?: Pick< + ChannelInstanceData, + "enabled" | "has_credentials" | "channel_type" + >, +): ChannelRuntimeStatus | null { + if (status) return status; + if (!instance) return null; + return getChannelStatusFallback(instance); +} + +export function getChannelCheckedLabel( + status: ChannelRuntimeStatus | null | undefined, + t: TFunction, +) { + const relative = formatRelativeTime(status?.checked_at); + if (!relative) return null; + return t("detail.checkedRelative", { + defaultValue: "Checked {{value}}", + value: relative, + }); +} + +export function getChannelFailureKindLabel( + kind: ChannelRuntimeStatus["failure_kind"], + t: TFunction, +) { + switch (kind) { + case "auth": + return t("failureKind.auth", { defaultValue: "Auth" }); + case "config": + return t("failureKind.config", { defaultValue: "Config" }); + case "network": + return t("failureKind.network", { defaultValue: "Network" }); + case "unknown": + return t("failureKind.unknown", { defaultValue: "Attention" }); + default: + return null; + } +} + +export function getChannelAttentionPriority( + status: ChannelRuntimeStatus | null | undefined, + enabled = true, +) { + if (!enabled) return 0; + if (!status) return 0; + switch (status?.state) { + case "failed": + return 5; + case "degraded": + return 4; + case "starting": + return 3; + case "registered": + return 2; + case "stopped": + return 1; + case "healthy": + return 0; + default: + return status?.running ? 0 : 1; + } +} + +export function getChannelStatusMeta( + status: ChannelRuntimeStatus | null | undefined, + enabled: boolean, + t: TFunction, +): ChannelStatusMeta { + if (!enabled) { + return { + dotClass: "bg-muted-foreground/40", + badgeVariant: "secondary", + label: t("disabled", { defaultValue: "Disabled" }), + surfaceClass: "border-border bg-card", + priority: 0, + attention: false, + }; + } + + if (!status) { + return { + dotClass: "bg-slate-300 dark:bg-slate-600", + badgeVariant: "secondary", + label: t("status.checking", { defaultValue: "Checking" }), + surfaceClass: "border-border bg-card", + priority: 0, + attention: false, + }; + } + + switch (status?.state) { + case "healthy": + return { + dotClass: "bg-emerald-500", + badgeVariant: "success", + label: t("status.running", { defaultValue: "Running" }), + surfaceClass: + "border-emerald-200/70 bg-emerald-500/[0.04] dark:border-emerald-500/20 dark:bg-emerald-500/10", + priority: 0, + attention: false, + }; + case "degraded": + return { + dotClass: "bg-amber-500", + badgeVariant: "warning", + label: t("status.degraded", { defaultValue: "Degraded" }), + surfaceClass: + "border-amber-200/80 bg-amber-500/[0.06] dark:border-amber-500/25 dark:bg-amber-500/10", + priority: 4, + attention: true, + }; + case "starting": + return { + dotClass: "bg-sky-500", + badgeVariant: "info", + label: t("status.starting", { defaultValue: "Starting" }), + surfaceClass: + "border-sky-200/80 bg-sky-500/[0.05] dark:border-sky-500/25 dark:bg-sky-500/10", + priority: 3, + attention: true, + }; + case "registered": + return { + dotClass: "bg-slate-400", + badgeVariant: "secondary", + label: t("status.registered", { defaultValue: "Configured" }), + surfaceClass: + "border-slate-200/80 bg-slate-500/[0.04] dark:border-slate-500/25 dark:bg-slate-500/10", + priority: 2, + attention: true, + }; + case "failed": + return { + dotClass: "bg-red-500", + badgeVariant: "destructive", + label: t("status.failed", { defaultValue: "Failed" }), + surfaceClass: + "border-red-200/80 bg-red-500/[0.05] dark:border-red-500/25 dark:bg-red-500/10", + priority: 5, + attention: true, + }; + case "stopped": + return { + dotClass: "bg-muted-foreground", + badgeVariant: "secondary", + label: t("status.stopped", { defaultValue: "Stopped" }), + surfaceClass: "border-border bg-muted/20", + priority: 1, + attention: true, + }; + default: + return status?.running + ? { + dotClass: "bg-emerald-500", + badgeVariant: "success", + label: t("status.running", { defaultValue: "Running" }), + surfaceClass: + "border-emerald-200/70 bg-emerald-500/[0.04] dark:border-emerald-500/20 dark:bg-emerald-500/10", + priority: 0, + attention: false, + } + : { + dotClass: "bg-muted-foreground", + badgeVariant: "secondary", + label: t("status.stopped", { defaultValue: "Stopped" }), + surfaceClass: "border-border bg-muted/20", + priority: 1, + attention: true, + }; + } +} + +export function getChannelRemediationMeta( + status: ChannelRuntimeStatus | null | undefined, + supportsReauth: boolean, + t: TFunction, +): ChannelRemediationMeta | null { + const remediation = status?.remediation; + if (!status || !remediation) return null; + + const rawTarget = remediation.target ?? "details"; + const target = + rawTarget === "reauth" && !supportsReauth ? "credentials" : rawTarget; + + const label = + target === "reauth" + ? t("actions.reauthShort", { defaultValue: "Re-auth" }) + : target === "credentials" + ? t("actions.openCredentials", { defaultValue: "Open credentials" }) + : target === "advanced" + ? t("actions.openAdvanced", { defaultValue: "Open advanced" }) + : t("actions.inspect", { defaultValue: "Inspect issue" }); + + return { + target, + label, + headline: remediation.headline, + hint: remediation.hint, + }; +} diff --git a/ui/web/src/pages/channels/channels-status-view.tsx b/ui/web/src/pages/channels/channels-status-view.tsx index d5404c28..5b71220e 100644 --- a/ui/web/src/pages/channels/channels-status-view.tsx +++ b/ui/web/src/pages/channels/channels-status-view.tsx @@ -1,6 +1,4 @@ import { Radio, RefreshCw } from "lucide-react"; -import type { TFunction } from "i18next"; -import i18next from "i18next"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -8,351 +6,30 @@ import { PageHeader } from "@/components/shared/page-header"; import { EmptyState } from "@/components/shared/empty-state"; import { CardSkeleton } from "@/components/shared/loading-skeleton"; import { useDeferredLoading } from "@/hooks/use-deferred-loading"; -import type { - ChannelInstanceData, - ChannelRuntimeStatus, -} from "@/types/channel"; +import type { ChannelRuntimeStatus } from "@/types/channel"; +import { + channelTypeLabels, + getChannelStatusMeta, + getChannelCheckedLabel, + getChannelFailureKindLabel, +} from "./channels-status-utils"; -export type ChannelStatus = ChannelRuntimeStatus; - -const channelTypeLabels: Record = { - telegram: "Telegram", - discord: "Discord", - slack: "Slack", - feishu: "Feishu / Lark", - zalo_oa: "Zalo OA", - zalo_personal: "Zalo Personal", - whatsapp: "WhatsApp", -}; - -type BadgeVariant = - | "secondary" - | "success" - | "warning" - | "info" - | "destructive"; - -export interface ChannelStatusMeta { - dotClass: string; - badgeVariant: BadgeVariant; - label: string; - surfaceClass: string; - priority: number; - attention: boolean; -} - -export interface ChannelRemediationMeta { - target: "credentials" | "advanced" | "reauth" | "details"; - label: string; - headline: string; - hint?: string; -} - -export { channelTypeLabels }; - -function translateChannelText(key: string, defaultValue: string) { - const value = i18next.t(`channels:${key}`, { defaultValue }); - return typeof value === "string" && value.length > 0 ? value : defaultValue; -} - -function getRelativeUnit(diffSeconds: number) { - const abs = Math.abs(diffSeconds); - if (abs < 60) return { value: Math.round(diffSeconds), unit: "second" as const }; - if (abs < 3600) return { value: Math.round(diffSeconds / 60), unit: "minute" as const }; - if (abs < 86400) return { value: Math.round(diffSeconds / 3600), unit: "hour" as const }; - if (abs < 604800) return { value: Math.round(diffSeconds / 86400), unit: "day" as const }; - return { value: Math.round(diffSeconds / 604800), unit: "week" as const }; -} - -export function formatRelativeTime(value?: string) { - if (!value) return null; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return null; - if (date.getUTCFullYear() <= 1) return null; - const diffSeconds = (date.getTime() - Date.now()) / 1000; - const { value: relativeValue, unit } = getRelativeUnit(diffSeconds); - const language = i18next.resolvedLanguage || i18next.language || undefined; - return new Intl.RelativeTimeFormat(language, { numeric: "auto" }).format( - relativeValue, - unit, - ); -} - -export function getChannelStatusFallback( - instance: Pick< - ChannelInstanceData, - "enabled" | "has_credentials" | "channel_type" - >, -): ChannelRuntimeStatus | null { - if (!instance.enabled || instance.has_credentials) { - return null; - } - - if (instance.channel_type === "zalo_personal") { - return { - enabled: true, - running: false, - state: "failed", - summary: translateChannelText( - "fallback.authRequiredSummary", - "Authentication required", - ), - detail: translateChannelText( - "fallback.authRequiredDetail", - "Channel instance is enabled but requires sign-in before it can connect.", - ), - failure_kind: "auth", - retryable: false, - remediation: { - code: "reauth", - headline: translateChannelText( - "fallback.authRequiredHeadline", - "Reconnect the channel session", - ), - hint: translateChannelText( - "fallback.authRequiredHint", - "Authenticate this channel again to restore the current session.", - ), - target: "reauth", - }, - }; - } - - return { - enabled: true, - running: false, - state: "failed", - summary: translateChannelText( - "fallback.missingCredentialsSummary", - "Missing credentials", - ), - detail: translateChannelText( - "fallback.missingCredentialsDetail", - "Channel instance is enabled but required credentials are incomplete.", - ), - failure_kind: "config", - retryable: false, - remediation: { - code: "open_credentials", - headline: translateChannelText( - "fallback.missingCredentialsHeadline", - "Complete required credentials", - ), - hint: translateChannelText( - "fallback.missingCredentialsHint", - "Open credentials and fill the missing or invalid values for this channel.", - ), - target: "credentials", - }, - }; -} - -export function getRenderableChannelStatus( - status: ChannelRuntimeStatus | null | undefined, - instance?: Pick< - ChannelInstanceData, - "enabled" | "has_credentials" | "channel_type" - >, -): ChannelRuntimeStatus | null { - if (status) return status; - if (!instance) return null; - return getChannelStatusFallback(instance); -} - -export function getChannelCheckedLabel( - status: ChannelRuntimeStatus | null | undefined, - t: TFunction, -) { - const relative = formatRelativeTime(status?.checked_at); - if (!relative) return null; - return t("detail.checkedRelative", { - defaultValue: "Checked {{value}}", - value: relative, - }); -} - -export function getChannelFailureKindLabel( - kind: ChannelRuntimeStatus["failure_kind"], - t: TFunction, -) { - switch (kind) { - case "auth": - return t("failureKind.auth", { defaultValue: "Auth" }); - case "config": - return t("failureKind.config", { defaultValue: "Config" }); - case "network": - return t("failureKind.network", { defaultValue: "Network" }); - case "unknown": - return t("failureKind.unknown", { defaultValue: "Attention" }); - default: - return null; - } -} - -export function getChannelAttentionPriority( - status: ChannelRuntimeStatus | null | undefined, - enabled = true, -) { - if (!enabled) return 0; - if (!status) return 0; - switch (status?.state) { - case "failed": - return 5; - case "degraded": - return 4; - case "starting": - return 3; - case "registered": - return 2; - case "stopped": - return 1; - case "healthy": - return 0; - default: - return status?.running ? 0 : 1; - } -} - -export function getChannelStatusMeta( - status: ChannelRuntimeStatus | null | undefined, - enabled: boolean, - t: TFunction, -): ChannelStatusMeta { - if (!enabled) { - return { - dotClass: "bg-muted-foreground/40", - badgeVariant: "secondary", - label: t("disabled", { defaultValue: "Disabled" }), - surfaceClass: "border-border bg-card", - priority: 0, - attention: false, - }; - } - - if (!status) { - return { - dotClass: "bg-slate-300 dark:bg-slate-600", - badgeVariant: "secondary", - label: t("status.checking", { defaultValue: "Checking" }), - surfaceClass: "border-border bg-card", - priority: 0, - attention: false, - }; - } - - switch (status?.state) { - case "healthy": - return { - dotClass: "bg-emerald-500", - badgeVariant: "success", - label: t("status.running", { defaultValue: "Running" }), - surfaceClass: - "border-emerald-200/70 bg-emerald-500/[0.04] dark:border-emerald-500/20 dark:bg-emerald-500/10", - priority: 0, - attention: false, - }; - case "degraded": - return { - dotClass: "bg-amber-500", - badgeVariant: "warning", - label: t("status.degraded", { defaultValue: "Degraded" }), - surfaceClass: - "border-amber-200/80 bg-amber-500/[0.06] dark:border-amber-500/25 dark:bg-amber-500/10", - priority: 4, - attention: true, - }; - case "starting": - return { - dotClass: "bg-sky-500", - badgeVariant: "info", - label: t("status.starting", { defaultValue: "Starting" }), - surfaceClass: - "border-sky-200/80 bg-sky-500/[0.05] dark:border-sky-500/25 dark:bg-sky-500/10", - priority: 3, - attention: true, - }; - case "registered": - return { - dotClass: "bg-slate-400", - badgeVariant: "secondary", - label: t("status.registered", { defaultValue: "Configured" }), - surfaceClass: - "border-slate-200/80 bg-slate-500/[0.04] dark:border-slate-500/25 dark:bg-slate-500/10", - priority: 2, - attention: true, - }; - case "failed": - return { - dotClass: "bg-red-500", - badgeVariant: "destructive", - label: t("status.failed", { defaultValue: "Failed" }), - surfaceClass: - "border-red-200/80 bg-red-500/[0.05] dark:border-red-500/25 dark:bg-red-500/10", - priority: 5, - attention: true, - }; - case "stopped": - return { - dotClass: "bg-muted-foreground", - badgeVariant: "secondary", - label: t("status.stopped", { defaultValue: "Stopped" }), - surfaceClass: "border-border bg-muted/20", - priority: 1, - attention: true, - }; - default: - return status?.running - ? { - dotClass: "bg-emerald-500", - badgeVariant: "success", - label: t("status.running", { defaultValue: "Running" }), - surfaceClass: - "border-emerald-200/70 bg-emerald-500/[0.04] dark:border-emerald-500/20 dark:bg-emerald-500/10", - priority: 0, - attention: false, - } - : { - dotClass: "bg-muted-foreground", - badgeVariant: "secondary", - label: t("status.stopped", { defaultValue: "Stopped" }), - surfaceClass: "border-border bg-muted/20", - priority: 1, - attention: true, - }; - } -} - -export function getChannelRemediationMeta( - status: ChannelRuntimeStatus | null | undefined, - supportsReauth: boolean, - t: TFunction, -): ChannelRemediationMeta | null { - const remediation = status?.remediation; - if (!status || !remediation) return null; - - const rawTarget = remediation.target ?? "details"; - const target = - rawTarget === "reauth" && !supportsReauth ? "credentials" : rawTarget; - - const label = - target === "reauth" - ? t("actions.reauthShort", { defaultValue: "Re-auth" }) - : target === "credentials" - ? t("actions.openCredentials", { defaultValue: "Open credentials" }) - : target === "advanced" - ? t("actions.openAdvanced", { defaultValue: "Open advanced" }) - : t("actions.inspect", { defaultValue: "Inspect issue" }); - - return { - target, - label, - headline: remediation.headline, - hint: remediation.hint, - }; -} +export type { ChannelStatus } from "./channels-status-utils"; +export type { ChannelStatusMeta, ChannelRemediationMeta } from "./channels-status-utils"; +export { + channelTypeLabels, + formatRelativeTime, + getChannelStatusFallback, + getRenderableChannelStatus, + getChannelCheckedLabel, + getChannelFailureKindLabel, + getChannelAttentionPriority, + getChannelStatusMeta, + getChannelRemediationMeta, +} from "./channels-status-utils"; interface ChannelsStatusViewProps { - channels: Record; + channels: Record; loading: boolean; spinning: boolean; refresh: () => void; diff --git a/ui/web/src/pages/overview/channel-attention-panel.tsx b/ui/web/src/pages/overview/channel-attention-panel.tsx new file mode 100644 index 00000000..35bd608e --- /dev/null +++ b/ui/web/src/pages/overview/channel-attention-panel.tsx @@ -0,0 +1,77 @@ +import { useTranslation } from "react-i18next"; +import type { ChannelStatusEntry } from "./types"; +import { formatRelativeTime, getChannelStatusMeta } from "@/pages/channels/channels-status-utils"; + +interface ChannelAttentionPanelProps { + attentionEntries: [string, ChannelStatusEntry][]; + previewLimit?: number; +} + +export function ChannelAttentionPanel({ + attentionEntries, + previewLimit = 2, +}: ChannelAttentionPanelProps) { + const { t } = useTranslation("overview"); + const attentionPreview = attentionEntries.slice(0, previewLimit); + const hiddenAttentionCount = Math.max(0, attentionEntries.length - attentionPreview.length); + + if (attentionPreview.length === 0) return null; + + return ( +
+
+

+ {t("systemHealth.needsAttention", { + defaultValue: "Needs attention", + })} +

+ + {t("systemHealth.channelsNeedingAttention", { + defaultValue: "{{count}} channels", + count: attentionEntries.length, + })} + +
+
+ {attentionPreview.map(([name, ch]) => { + const meta = getChannelStatusMeta(ch, ch.enabled, t); + const checked = formatRelativeTime(ch.checked_at); + return ( +
+ +
+
+ {name} + + {meta.label} + + {checked && ( + + {checked} + + )} +
+

+ {ch.summary || meta.label} +

+
+
+ ); + })} + {hiddenAttentionCount > 0 && ( +

+ {t("systemHealth.moreAttention", { + defaultValue: "+{{count}} more", + count: hiddenAttentionCount, + })} +

+ )} +
+
+ ); +} diff --git a/ui/web/src/pages/overview/system-health-card.tsx b/ui/web/src/pages/overview/system-health-card.tsx index 19cdb839..2d02eeef 100644 --- a/ui/web/src/pages/overview/system-health-card.tsx +++ b/ui/web/src/pages/overview/system-health-card.tsx @@ -16,7 +16,8 @@ import type { HealthPayload, ChannelStatusEntry } from "./types"; import type { RuntimeInfo } from "@/pages/skills/hooks/use-runtimes"; import { formatUptime } from "./hooks/use-live-uptime"; import { cleanVersion } from "@/lib/clean-version"; -import { formatRelativeTime, getChannelAttentionPriority, getChannelStatusMeta } from "@/pages/channels/channels-status-view"; +import { getChannelAttentionPriority, getChannelStatusMeta } from "@/pages/channels/channels-status-view"; +import { ChannelAttentionPanel } from "./channel-attention-panel"; function StatusDot({ ok }: { ok: boolean | undefined }) { if (ok === undefined) @@ -86,8 +87,6 @@ export function SystemHealthCard({ getChannelAttentionPriority(b[1], b[1].enabled) - getChannelAttentionPriority(a[1], a[1].enabled), ); - const attentionPreview = attentionEntries.slice(0, 2); - const hiddenAttentionCount = Math.max(0, attentionEntries.length - attentionPreview.length); return ( @@ -225,63 +224,7 @@ export function SystemHealthCard({ ); })}
- {attentionPreview.length > 0 && ( -
-
-

- {t("systemHealth.needsAttention", { - defaultValue: "Needs attention", - })} -

- - {t("systemHealth.channelsNeedingAttention", { - defaultValue: "{{count}} channels", - count: attentionEntries.length, - })} - -
-
- {attentionPreview.map(([name, ch]) => { - const meta = getChannelStatusMeta(ch, ch.enabled, t); - const checked = formatRelativeTime(ch.checked_at); - return ( -
- -
-
- {name} - - {meta.label} - - {checked && ( - - {checked} - - )} -
-

- {ch.summary || meta.label} -

-
-
- ); - })} - {hiddenAttentionCount > 0 && ( -

- {t("systemHealth.moreAttention", { - defaultValue: "+{{count}} more", - count: hiddenAttentionCount, - })} -

- )} -
-
- )} +
)}