refactor(channels): extract health model and modularize UI components

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
This commit is contained in:
viettranx committed 2026-04-02 09:45:41 +07:00
1 parent b5c4d1de2a
commit 83bcb16597
14 files changed
+999 -912

No files matched your search

+1 -1
View File
@@ -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)
+7 -346
View File
@@ -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()
+348
View File
@@ -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,
}
}
}
@@ -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
@@ -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);
@@ -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",
@@ -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": "凭据",
@@ -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, unknown>) => 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 };
}
@@ -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({
<div className="p-3 sm:p-4">
<div className="max-w-4xl space-y-4">
{showDiagnosticsCard && status && (
<div
className={cn(
"rounded-xl border p-4",
statusMeta.surfaceClass,
)}
>
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_220px]">
<div>
<div className="flex items-center gap-2 text-sm font-medium">
<AlertTriangle className="h-4 w-4" />
<span>
{t("detail.whatHappened", {
defaultValue: "What happened",
})}
</span>
</div>
<p className="mt-2 text-base font-semibold">
{status.summary || statusMeta.label}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{status.remediation?.headline || diagnosticsHint}
</p>
<div className="mt-4">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{t("detail.recommendedAction", {
defaultValue: "Recommended action",
})}
</p>
<p className="mt-2 text-sm font-medium">
{remediation?.label ||
t("actions.inspect", { defaultValue: "Inspect issue" })}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{diagnosticsHint}
</p>
{remediation && remediation.target !== "details" && (
<Button
size="sm"
onClick={handleRemediationAction}
className="mt-3 sm:hidden"
>
{remediation.label}
</Button>
)}
</div>
{status.detail && (
<details className="mt-4 rounded-lg border border-border/80 bg-background/60 p-3">
<summary className="cursor-pointer text-sm font-medium">
{t("detail.technicalDetail", {
defaultValue: "Technical detail",
})}
</summary>
<p className="mt-2 break-words text-xs text-muted-foreground">
{status.detail}
</p>
</details>
)}
</div>
<div>
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{t("detail.timeline.title", { defaultValue: "Timeline" })}
</p>
<div className="mt-3 space-y-2">
{timelineItems.length > 0 ? (
timelineItems.map((item) => (
<div
key={item.label}
className="flex items-start justify-between gap-4 rounded-lg bg-background/60 px-3 py-2"
>
<span className="text-xs text-muted-foreground">
{item.label}
</span>
<span className="text-right text-xs font-medium tabular-nums">
{item.value}
</span>
</div>
))
) : (
<div className="rounded-lg bg-background/60 px-3 py-2 text-xs text-muted-foreground">
{checkedLabel ||
t("detail.timeline.noData", {
defaultValue: "No recent channel checks recorded yet.",
})}
</div>
)}
</div>
</div>
</div>
</div>
<ChannelDiagnosticsCard
status={status}
statusMeta={statusMeta}
remediation={remediation}
checkedLabel={checkedLabel}
diagnosticsHint={diagnosticsHint}
timelineItems={timelineItems}
onRemediationAction={handleRemediationAction}
/>
)}
{neutralHealthNote && (
@@ -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 (
<div
className={cn(
"rounded-xl border p-4",
statusMeta.surfaceClass,
)}
>
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_220px]">
<div>
<div className="flex items-center gap-2 text-sm font-medium">
<AlertTriangle className="h-4 w-4" />
<span>
{t("detail.whatHappened", {
defaultValue: "What happened",
})}
</span>
</div>
<p className="mt-2 text-base font-semibold">
{status.summary || statusMeta.label}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{status.remediation?.headline || diagnosticsHint}
</p>
<div className="mt-4">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{t("detail.recommendedAction", {
defaultValue: "Recommended action",
})}
</p>
<p className="mt-2 text-sm font-medium">
{remediation?.label ||
t("actions.inspect", { defaultValue: "Inspect issue" })}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{diagnosticsHint}
</p>
{remediation && remediation.target !== "details" && (
<Button
size="sm"
onClick={onRemediationAction}
className="mt-3 sm:hidden"
>
{remediation.label}
</Button>
)}
</div>
{status.detail && (
<details className="mt-4 rounded-lg border border-border/80 bg-background/60 p-3">
<summary className="cursor-pointer text-sm font-medium">
{t("detail.technicalDetail", {
defaultValue: "Technical detail",
})}
</summary>
<p className="mt-2 break-words text-xs text-muted-foreground">
{status.detail}
</p>
</details>
)}
</div>
<div>
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{t("detail.timeline.title", { defaultValue: "Timeline" })}
</p>
<div className="mt-3 space-y-2">
{timelineItems.length > 0 ? (
timelineItems.map((item) => (
<div
key={item.label}
className="flex items-start justify-between gap-4 rounded-lg bg-background/60 px-3 py-2"
>
<span className="text-xs text-muted-foreground">
{item.label}
</span>
<span className="text-right text-xs font-medium tabular-nums">
{item.value}
</span>
</div>
))
) : (
<div className="rounded-lg bg-background/60 px-3 py-2 text-xs text-muted-foreground">
{checkedLabel ||
t("detail.timeline.noData", {
defaultValue: "No recent channel checks recorded yet.",
})}
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -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<string, string> = {
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,
};
}
@@ -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<string, string> = {
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<string, ChannelStatus>;
channels: Record<string, ChannelRuntimeStatus>;
loading: boolean;
spinning: boolean;
refresh: () => void;
@@ -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 (
<div className="mt-3 rounded-lg border border-amber-200/70 bg-amber-500/[0.05] p-3 dark:border-amber-500/20 dark:bg-amber-500/10">
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t("systemHealth.needsAttention", {
defaultValue: "Needs attention",
})}
</p>
<span className="text-xs text-muted-foreground">
{t("systemHealth.channelsNeedingAttention", {
defaultValue: "{{count}} channels",
count: attentionEntries.length,
})}
</span>
</div>
<div className="space-y-2">
{attentionPreview.map(([name, ch]) => {
const meta = getChannelStatusMeta(ch, ch.enabled, t);
const checked = formatRelativeTime(ch.checked_at);
return (
<div
key={name}
className="flex items-start gap-2 text-sm"
>
<span
className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${meta.dotClass}`}
/>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{name}</span>
<span className="text-xs text-muted-foreground">
{meta.label}
</span>
{checked && (
<span className="text-xs text-muted-foreground">
{checked}
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">
{ch.summary || meta.label}
</p>
</div>
</div>
);
})}
{hiddenAttentionCount > 0 && (
<p className="text-xs text-muted-foreground">
{t("systemHealth.moreAttention", {
defaultValue: "+{{count}} more",
count: hiddenAttentionCount,
})}
</p>
)}
</div>
</div>
);
}
@@ -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 (
<Card className="gap-4">
@@ -225,63 +224,7 @@ export function SystemHealthCard({
);
})}
</div>
{attentionPreview.length > 0 && (
<div className="mt-3 rounded-lg border border-amber-200/70 bg-amber-500/[0.05] p-3 dark:border-amber-500/20 dark:bg-amber-500/10">
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t("systemHealth.needsAttention", {
defaultValue: "Needs attention",
})}
</p>
<span className="text-xs text-muted-foreground">
{t("systemHealth.channelsNeedingAttention", {
defaultValue: "{{count}} channels",
count: attentionEntries.length,
})}
</span>
</div>
<div className="space-y-2">
{attentionPreview.map(([name, ch]) => {
const meta = getChannelStatusMeta(ch, ch.enabled, t);
const checked = formatRelativeTime(ch.checked_at);
return (
<div
key={name}
className="flex items-start gap-2 text-sm"
>
<span
className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${meta.dotClass}`}
/>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{name}</span>
<span className="text-xs text-muted-foreground">
{meta.label}
</span>
{checked && (
<span className="text-xs text-muted-foreground">
{checked}
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">
{ch.summary || meta.label}
</p>
</div>
</div>
);
})}
{hiddenAttentionCount > 0 && (
<p className="text-xs text-muted-foreground">
{t("systemHealth.moreAttention", {
defaultValue: "+{{count}} more",
count: hiddenAttentionCount,
})}
</p>
)}
</div>
</div>
)}
<ChannelAttentionPanel attentionEntries={attentionEntries} />
</div>
)}
</CardContent>