mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 06:16:10 +00:00
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:
1 parent
b5c4d1de2a
commit
83bcb16597
14 files changed
+999
-912
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user