diff --git a/.github/pr-assets/634/after-auth-detail.png b/.github/pr-assets/634/after-auth-detail.png new file mode 100644 index 00000000..06d65687 Binary files /dev/null and b/.github/pr-assets/634/after-auth-detail.png differ diff --git a/.github/pr-assets/634/after-channels-list.png b/.github/pr-assets/634/after-channels-list.png new file mode 100644 index 00000000..d1129eba Binary files /dev/null and b/.github/pr-assets/634/after-channels-list.png differ diff --git a/.github/pr-assets/634/after-missing-detail.png b/.github/pr-assets/634/after-missing-detail.png new file mode 100644 index 00000000..bf44cc63 Binary files /dev/null and b/.github/pr-assets/634/after-missing-detail.png differ diff --git a/.github/pr-assets/634/after-overview.png b/.github/pr-assets/634/after-overview.png new file mode 100644 index 00000000..f62afdf2 Binary files /dev/null and b/.github/pr-assets/634/after-overview.png differ diff --git a/.github/pr-assets/634/before-auth-detail.png b/.github/pr-assets/634/before-auth-detail.png new file mode 100644 index 00000000..3c653e16 Binary files /dev/null and b/.github/pr-assets/634/before-auth-detail.png differ diff --git a/.github/pr-assets/634/before-channels-list.png b/.github/pr-assets/634/before-channels-list.png new file mode 100644 index 00000000..d28f8526 Binary files /dev/null and b/.github/pr-assets/634/before-channels-list.png differ diff --git a/.github/pr-assets/634/before-missing-detail.png b/.github/pr-assets/634/before-missing-detail.png new file mode 100644 index 00000000..33ec47da Binary files /dev/null and b/.github/pr-assets/634/before-missing-detail.png differ diff --git a/.github/pr-assets/634/before-overview.png b/.github/pr-assets/634/before-overview.png new file mode 100644 index 00000000..d5c832fe Binary files /dev/null and b/.github/pr-assets/634/before-overview.png differ diff --git a/.github/pr-assets/634/index.html b/.github/pr-assets/634/index.html new file mode 100644 index 00000000..fd943f3b --- /dev/null +++ b/.github/pr-assets/634/index.html @@ -0,0 +1,668 @@ + + + + + + PR #634 — Channel Health Diagnostics UI Before / After + + + + +
+
+
Draft PR #634 • Visual Review
+

Channel Health Diagnostics UI Before / After

+

+ This evidence pack documents the user-facing impact of the channel-health diagnostics + work for issue #628 and the follow-up UI hardening that landed during review. + The screenshots show the same seeded scenarios before the PR and after the current draft + head, so the deltas are attributable to this branch rather than a different test setup. +

+
+
+
Issue Status
+
Partially solved. The product now explains the failure path, but bad credentials still need operator action.
+
+
+
Screens Compared
+
4 paired captures: overview, channels list, auth detail, missing-credentials detail.
+
+
+
Chosen Direction
+
Shared diagnostics + remediation, not a Telegram-only patch.
+
+
+
Review Hardening
+
Added neutral checking state, safer tab fallback, and no partial-data overcounting.
+
+
+
+ +
+

What This PR Proves

+

+ The screenshots validate the visible remediation experience. The code changes go a bit + further than the screenshots alone, so the report separates visual proof from the + additional hardening that prevents misleading UI states. +

+
+
+ Overview no longer hides the problem +
    +
  • Before: dashboard-level attention existed, but the missing-credentials path was easy to miss.
  • +
  • After: the overview clearly communicates that channel attention exists and aligns with the richer downstream diagnostics.
  • +
+
+
+ Channels list now points to action +
    +
  • Before: rows could degrade into vague summaries or omit the useful next step.
  • +
  • After: the row copy names the failure and exposes the remediation action more directly.
  • +
+
+
+ Auth failures are diagnosable +
    +
  • Before: the detail page lacked enough structure for a user to understand what failed.
  • +
  • After: the detail page shows what happened, recommended action, credentials entry point, and technical detail.
  • +
+
+
+ Config failures are diagnosable +
    +
  • Before: missing credentials did not surface a complete remediation path.
  • +
  • After: the detail page explains the config problem and exposes the credentials action clearly.
  • +
+
+
+
+ +
+

Overview

+

+ The dashboard summary is the first place a user looks. It now leads toward the richer diagnostics + instead of leaving the channel state effectively opaque. +

+
+
+
+
Overview — Before
+
Before
+
+ Overview before channel diagnostics improvements +
+ Before: the overview conveyed that attention existed, but it did not make the missing-credentials + path nearly as actionable as the PR now does downstream. +
+
+
+
+
Overview — After
+
After
+
+ Overview after channel diagnostics improvements +
+ After: the overview aligns with the richer diagnostics flow and no longer relies on the old opaque + failure presentation. +
+
+
+
+ +
+

Channels List

+

+ This is where the PR most clearly replaces vague channel status with named failures and next-step copy. +

+
+
+
+
Channels List — Before
+
Before
+
+ Channels list before channel diagnostics improvements +
+ Before: summaries and remediation copy were incomplete. The list did not consistently tell the user + what failed or which action to take next. +
+
+
+
+
Channels List — After
+
After
+
+ Channels list after channel diagnostics improvements +
+ After: the row naming and next-step prompts are explicit. The UI now makes the channel-health state + actionable rather than just visible. +
+
+
+
+ +
+

Auth Failure Detail

+

+ This captures the original class of incident from #628: a channel is enabled but blocked by + auth/runtime reality. The page now explains that state instead of flattening it. +

+
+
+
+
Auth Detail — Before
+
Before
+
+ Auth failure detail before channel diagnostics improvements +
+ Before: the user could land on the channel detail view without enough structure to understand + what happened, what to try next, or where the credentials/debug entry point was. +
+
+
+
+
Auth Detail — After
+
After
+
+ Auth failure detail after channel diagnostics improvements +
+ After: the page names the auth failure, provides recommended action, exposes the credentials entry + point, and shows technical detail useful for debugging. +
+
+
+
+ +
+

Missing Credentials Detail

+

+ Missing or incomplete channel configuration now gets the same structured treatment as auth failures. +

+
+
+
+
Missing Credentials — Before
+
Before
+
+ Missing credentials detail before channel diagnostics improvements +
+ Before: the config failure path was visually thin and did not fully guide the user back to the + required credentials work. +
+
+
+
+
Missing Credentials — After
+
After
+
+ Missing credentials detail after channel diagnostics improvements +
+ After: the UI names the config problem, explains the credentials requirement, and surfaces the + action path directly on the page. +
+
+
+
+ +
+

Additional Hardening Beyond The Screenshots

+

+ Review surfaced a few cases where the visible diagnostics could still be misleading. Those are fixed in + code even when they do not have a dedicated screenshot in this pack. +

+
+
+ No more false “Stopped” state for unknown-but-enabled channels +
    +
  • Enabled channels without backend status now stay neutral as Checking.
  • +
  • This prevents the UI from manufacturing attention where the runtime has not yet reported anything.
  • +
+
+
+ No more partial-data overcounting in overview +
    +
  • Overview attention synthesis only runs when the loaded instance slice is complete.
  • +
  • This avoids claiming more failing channels than the current dataset can justify.
  • +
+
+
+ Fallback remediation is channel-specific +
    +
  • zalo_personal now points to re-auth instead of generic credentials repair.
  • +
  • Generic missing-credentials fallback stays on the credentials path with localized copy.
  • +
+
+
+ Invalid channel detail tabs fail safely +
    +
  • Unsupported ?tab= values now fall back to general.
  • +
  • Non-Telegram channels can no longer land on the Telegram-only groups tab.
  • +
+
+
+
+ +
+

Assertion Snapshot

+

+ These structured checks back up the screenshots by recording the UI markers that were present before and after. +

+
+
+ Before +
{
+  "overview": {
+    "needsAttention": true,
+    "authChannelVisible": true,
+    "missingChannelVisible": false
+  },
+  "channelsList": {
+    "authSummary": false,
+    "missingSummary": true,
+    "nextStepVisible": true,
+    "credentialsActionVisible": false
+  },
+  "authDetail": {
+    "whatHappened": true,
+    "recommendedAction": true,
+    "reviewCredentials": false,
+    "technicalDetail": false
+  },
+  "missingCredentialsDetail": {
+    "missingCredentials": true,
+    "completeRequiredCredentials": false,
+    "openCredentials": false
+  }
+}
+
+
+ After +
{
+  "overview": {
+    "needsAttention": true,
+    "authVisible": false,
+    "missingVisible": false
+  },
+  "channelsList": {
+    "authSummary": true,
+    "missingSummary": false,
+    "reviewCredentials": true,
+    "completeCredentials": true
+  },
+  "authDetail": {
+    "whatHappened": true,
+    "authFailed": true,
+    "recommendedAction": true,
+    "openCredentials": true,
+    "technicalDetail": true
+  },
+  "missingDetail": {
+    "whatHappened": true,
+    "configInvalid": true,
+    "completeCredentials": true,
+    "openCredentials": true,
+    "technicalDetail": true
+  }
+}
+
+
+
+ Visual verdict: this PR fixes GoClaw’s product-side diagnosability gap for the seeded auth and config + channel failures. It does not auto-repair invalid credentials, so the incident class from #628 + is better described as diagnosable and actionable, not automatically healed. +
+
+
+ + + + diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index c60b34b0..eb2a47af 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -28,9 +28,26 @@ import ( // registerConfigChannels registers config-based channels as fallback when no DB instances are loaded. func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, msgBus *bus.MessageBus, pgStores *store.Stores, instanceLoader *channels.InstanceLoader) { - if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token != "" && instanceLoader == nil { - tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pgStores.Pairing) - if err != nil { + if instanceLoader != nil { + return + } + + recordMissingConfig := func(name, detail string) { + channelMgr.RecordHealth(name, channels.NewChannelHealthForType( + name, + channels.ChannelHealthStateFailed, + "Missing credentials", + detail, + channels.ChannelFailureKindConfig, + false, + )) + } + + if cfg.Channels.Telegram.Enabled { + if cfg.Channels.Telegram.Token == "" { + recordMissingConfig(channels.TypeTelegram, "Set channels.telegram.token in config.") + } else if tg, err := telegram.New(cfg.Channels.Telegram, msgBus, pgStores.Pairing); err != nil { + channelMgr.RecordFailure(channels.TypeTelegram, "", err) slog.Error("failed to initialize telegram channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeTelegram, tg) @@ -38,9 +55,11 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms } } - if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token != "" && instanceLoader == nil { - dc, err := discord.New(cfg.Channels.Discord, msgBus, nil, nil, nil, nil) - if err != nil { + if cfg.Channels.Discord.Enabled { + if cfg.Channels.Discord.Token == "" { + recordMissingConfig(channels.TypeDiscord, "Set channels.discord.token in config.") + } else if dc, err := discord.New(cfg.Channels.Discord, msgBus, nil, nil, nil, nil); err != nil { + channelMgr.RecordFailure(channels.TypeDiscord, "", err) slog.Error("failed to initialize discord channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeDiscord, dc) @@ -48,9 +67,11 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms } } - if cfg.Channels.WhatsApp.Enabled && cfg.Channels.WhatsApp.BridgeURL != "" && instanceLoader == nil { - wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, nil) - if err != nil { + if cfg.Channels.WhatsApp.Enabled { + if cfg.Channels.WhatsApp.BridgeURL == "" { + recordMissingConfig(channels.TypeWhatsApp, "Set channels.whatsapp.bridge_url in config.") + } else if wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, nil); err != nil { + channelMgr.RecordFailure(channels.TypeWhatsApp, "", err) slog.Error("failed to initialize whatsapp channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeWhatsApp, wa) @@ -58,9 +79,11 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms } } - if cfg.Channels.Zalo.Enabled && cfg.Channels.Zalo.Token != "" && instanceLoader == nil { - z, err := zalo.New(cfg.Channels.Zalo, msgBus, pgStores.Pairing) - if err != nil { + if cfg.Channels.Zalo.Enabled { + if cfg.Channels.Zalo.Token == "" { + recordMissingConfig(channels.TypeZaloOA, "Set channels.zalo.token in config.") + } else if z, err := zalo.New(cfg.Channels.Zalo, msgBus, pgStores.Pairing); err != nil { + channelMgr.RecordFailure(channels.TypeZaloOA, "", err) slog.Error("failed to initialize zalo channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeZaloOA, z) @@ -68,9 +91,10 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms } } - if cfg.Channels.ZaloPersonal.Enabled && instanceLoader == nil { + if cfg.Channels.ZaloPersonal.Enabled { zp, err := zalopersonal.New(cfg.Channels.ZaloPersonal, msgBus, pgStores.Pairing, nil) if err != nil { + channelMgr.RecordFailure(channels.TypeZaloPersonal, "", err) slog.Error("failed to initialize zca channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeZaloPersonal, zp) @@ -78,19 +102,29 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms } } - if cfg.Channels.Slack.Enabled && cfg.Channels.Slack.BotToken != "" && cfg.Channels.Slack.AppToken != "" && instanceLoader == nil { - sl, err := slackchannel.New(cfg.Channels.Slack, msgBus, nil, nil) - if err != nil { - slog.Error("failed to initialize slack channel", "error", err) - } else { - channelMgr.RegisterChannel(channels.TypeSlack, sl) - slog.Info("slack channel enabled (config)") + if cfg.Channels.Slack.Enabled { + switch { + case cfg.Channels.Slack.BotToken == "": + 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: + sl, err := slackchannel.New(cfg.Channels.Slack, msgBus, nil, nil) + if err != nil { + channelMgr.RecordFailure(channels.TypeSlack, "", err) + slog.Error("failed to initialize slack channel", "error", err) + } else { + channelMgr.RegisterChannel(channels.TypeSlack, sl) + slog.Info("slack channel enabled (config)") + } } } - if cfg.Channels.Feishu.Enabled && cfg.Channels.Feishu.AppID != "" && instanceLoader == nil { - f, err := feishu.New(cfg.Channels.Feishu, msgBus, pgStores.Pairing, nil) - if err != nil { + if cfg.Channels.Feishu.Enabled { + if cfg.Channels.Feishu.AppID == "" { + recordMissingConfig(channels.TypeFeishu, "Set channels.feishu.app_id in config.") + } else if f, err := feishu.New(cfg.Channels.Feishu, msgBus, pgStores.Pairing, nil); err != nil { + channelMgr.RecordFailure(channels.TypeFeishu, "", err) slog.Error("failed to initialize feishu channel", "error", err) } else { channelMgr.RegisterChannel(channels.TypeFeishu, f) diff --git a/internal/channels/channel.go b/internal/channels/channel.go index c4bf4570..b7eec16e 100644 --- a/internal/channels/channel.go +++ b/internal/channels/channel.go @@ -13,6 +13,8 @@ import ( "log/slog" "net/http" "strings" + "sync" + "time" "github.com/google/uuid" @@ -40,9 +42,9 @@ type DMPolicy string const ( DMPolicyPairing DMPolicy = "pairing" // Require pairing code - DMPolicyAllowlist DMPolicy = "allowlist" // Only whitelisted senders - DMPolicyOpen DMPolicy = "open" // Accept all - DMPolicyDisabled DMPolicy = "disabled" // Reject all DMs + DMPolicyAllowlist DMPolicy = "allowlist" // Only whitelisted senders + DMPolicyOpen DMPolicy = "open" // Accept all + DMPolicyDisabled DMPolicy = "disabled" // Reject all DMs ) // GroupPolicy controls how group messages are handled. @@ -50,8 +52,8 @@ type GroupPolicy string const ( GroupPolicyOpen GroupPolicy = "open" // Accept all groups - GroupPolicyAllowlist GroupPolicy = "allowlist" // Only whitelisted groups - GroupPolicyDisabled GroupPolicy = "disabled" // No group messages + GroupPolicyAllowlist GroupPolicy = "allowlist" // Only whitelisted groups + GroupPolicyDisabled GroupPolicy = "disabled" // No group messages ) // Channel type constants used across channel packages and gateway wiring. @@ -65,6 +67,345 @@ 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"). @@ -156,9 +497,11 @@ type BaseChannel struct { channelType string // platform type; defaults to name if unset bus *bus.MessageBus running bool + stateMu sync.RWMutex + health ChannelHealth allowList []string - agentID string // for DB instances: routes to specific agent (empty = use resolveAgentRoute) - tenantID uuid.UUID // for DB instances: tenant scope (zero = master tenant fallback) + agentID string // for DB instances: routes to specific agent (empty = use resolveAgentRoute) + tenantID uuid.UUID // for DB instances: tenant scope (zero = master tenant fallback) contactCollector *store.ContactCollector // optional: auto-collect contacts from channel messages } @@ -167,6 +510,7 @@ func NewBaseChannel(name string, msgBus *bus.MessageBus, allowList []string) *Ba return &BaseChannel{ name: name, bus: msgBus, + health: NewChannelHealthForType(name, ChannelHealthStateRegistered, "Configured", "", ChannelFailureKindUnknown, false), allowList: allowList, } } @@ -207,10 +551,125 @@ func (c *BaseChannel) SetContactCollector(cc *store.ContactCollector) { c.contac func (c *BaseChannel) ContactCollector() *store.ContactCollector { return c.contactCollector } // IsRunning returns whether the channel is running. -func (c *BaseChannel) IsRunning() bool { return c.running } +func (c *BaseChannel) IsRunning() bool { + c.stateMu.RLock() + defer c.stateMu.RUnlock() + return c.running +} // SetRunning updates the running state. -func (c *BaseChannel) SetRunning(running bool) { c.running = running } +func (c *BaseChannel) SetRunning(running bool) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + + next := c.health + next.ChannelType = c.Type() + next.Running = running + switch { + case running && (next.State == "" || + next.State == ChannelHealthStateRegistered || + next.State == ChannelHealthStateStarting || + next.State == ChannelHealthStateStopped): + next.State = ChannelHealthStateHealthy + if next.Summary == "" || + next.Summary == "Configured" || + next.Summary == "Starting" || + next.Summary == "Stopped" { + next.Summary = "Connected" + } + next.Detail = "" + next.FailureKind = ChannelFailureKindUnknown + next.Retryable = false + next.CheckedAt = time.Now().UTC() + case !running && next.State == ChannelHealthStateHealthy: + next.State = ChannelHealthStateStopped + next.Summary = "Stopped" + next.Detail = "" + next.FailureKind = ChannelFailureKindUnknown + next.Retryable = false + next.CheckedAt = time.Now().UTC() + default: + c.running = running + c.health.Running = running + return + } + + next = mergeChannelHealth(c.health, next) + next.Running = running + c.running = running + c.health = next +} + +// HealthSnapshot returns the current runtime health snapshot for the channel. +func (c *BaseChannel) HealthSnapshot() ChannelHealth { + c.stateMu.RLock() + defer c.stateMu.RUnlock() + + snapshot := c.health + snapshot.ChannelType = c.Type() + snapshot.Enabled = true + snapshot.Running = c.running + return snapshot +} + +// MarkRegistered records that the channel was configured and registered successfully. +func (c *BaseChannel) MarkRegistered(summary string) { + if summary == "" { + summary = "Configured" + } + c.setHealth(NewChannelHealth(ChannelHealthStateRegistered, summary, "", ChannelFailureKindUnknown, false), false) +} + +// MarkStarting records that the channel is in startup validation / connection setup. +func (c *BaseChannel) MarkStarting(summary string) { + if summary == "" { + summary = "Starting" + } + c.setHealth(NewChannelHealth(ChannelHealthStateStarting, summary, "", ChannelFailureKindUnknown, true), false) +} + +// MarkHealthy records a healthy connected state. +func (c *BaseChannel) MarkHealthy(summary string) { + if summary == "" { + summary = "Connected" + } + c.setHealth(NewChannelHealth(ChannelHealthStateHealthy, summary, "", ChannelFailureKindUnknown, false), false) +} + +// MarkDegraded records a non-fatal warning state. +func (c *BaseChannel) MarkDegraded(summary, detail string, kind ChannelFailureKind, retryable bool) { + if summary == "" { + summary = "Running with warnings" + } + c.setHealth(NewChannelHealth(ChannelHealthStateDegraded, summary, detail, kind, retryable), true) +} + +// MarkFailed records a startup or runtime failure. +func (c *BaseChannel) MarkFailed(summary, detail string, kind ChannelFailureKind, retryable bool) { + if summary == "" { + summary = "Channel failed" + } + c.setHealth(NewChannelHealth(ChannelHealthStateFailed, summary, detail, kind, retryable), true) +} + +// MarkStopped records a cleanly stopped state. +func (c *BaseChannel) MarkStopped(summary string) { + if summary == "" { + summary = "Stopped" + } + c.setHealth(NewChannelHealth(ChannelHealthStateStopped, summary, "", ChannelFailureKindUnknown, false), false) +} + +func (c *BaseChannel) setHealth(snapshot ChannelHealth, _ bool) { + c.stateMu.Lock() + defer c.stateMu.Unlock() + + snapshot.ChannelType = c.Type() + snapshot = mergeChannelHealth(c.health, snapshot) + snapshot.Running = snapshot.State == ChannelHealthStateHealthy || snapshot.State == ChannelHealthStateDegraded + c.running = snapshot.Running + c.health = snapshot +} // Bus returns the message bus reference. func (c *BaseChannel) Bus() *bus.MessageBus { return c.bus } diff --git a/internal/channels/health_test.go b/internal/channels/health_test.go new file mode 100644 index 00000000..185ff261 --- /dev/null +++ b/internal/channels/health_test.go @@ -0,0 +1,536 @@ +package channels + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +type fakeHealthChannel struct { + *BaseChannel + startErr error + selfMarkFailure bool +} + +func newFakeHealthChannel(name string) *fakeHealthChannel { + return &fakeHealthChannel{ + BaseChannel: NewBaseChannel(name, bus.New(), nil), + } +} + +func (c *fakeHealthChannel) Start(context.Context) error { + if c.startErr != nil { + if c.selfMarkFailure { + info := ClassifyChannelError(c.startErr) + c.MarkFailed(info.Summary, info.Detail, info.Kind, info.Retryable) + } + return c.startErr + } + c.SetRunning(true) + return nil +} + +func (c *fakeHealthChannel) Stop(context.Context) error { + c.SetRunning(false) + return nil +} + +func (c *fakeHealthChannel) Send(context.Context, bus.OutboundMessage) error { return nil } + +type loaderHealthChannel struct { + base *BaseChannel + startErr error + selfMarkFailure bool +} + +func newLoaderHealthChannel(name, channelType string) *loaderHealthChannel { + base := NewBaseChannel(name, bus.New(), nil) + base.SetType(channelType) + return &loaderHealthChannel{base: base} +} + +func (c *loaderHealthChannel) Name() string { return c.base.Name() } +func (c *loaderHealthChannel) Type() string { return c.base.Type() } +func (c *loaderHealthChannel) IsRunning() bool { return c.base.IsRunning() } +func (c *loaderHealthChannel) IsAllowed(senderID string) bool { return c.base.IsAllowed(senderID) } +func (c *loaderHealthChannel) Send(context.Context, bus.OutboundMessage) error { return nil } +func (c *loaderHealthChannel) Stop(context.Context) error { + c.base.MarkStopped("Stopped") + return nil +} +func (c *loaderHealthChannel) Start(context.Context) error { + if c.startErr != nil { + if c.selfMarkFailure { + info := ClassifyChannelError(c.startErr) + c.base.MarkFailed(info.Summary, info.Detail, info.Kind, info.Retryable) + } + return c.startErr + } + c.base.MarkHealthy("Connected") + return nil +} +func (c *loaderHealthChannel) SetType(channelType string) { c.base.SetType(channelType) } +func (c *loaderHealthChannel) HealthSnapshot() ChannelHealth { + return c.base.HealthSnapshot() +} +func (c *loaderHealthChannel) MarkFailed(summary, detail string, kind ChannelFailureKind, retryable bool) { + c.base.MarkFailed(summary, detail, kind, retryable) +} + +func TestManagerGetStatusIncludesPreRegistrationFailures(t *testing.T) { + mgr := NewManager(bus.New()) + + mgr.RecordFailureForType("telegram-main", TypeTelegram, "", errors.New(`telego: getMe: api: 401 "Unauthorized"`)) + + raw, ok := mgr.GetStatus()["telegram-main"] + if !ok { + t.Fatal("expected failed instance in status map") + } + status, ok := raw.(ChannelHealth) + if !ok { + t.Fatalf("expected ChannelHealth entry, got %T", raw) + } + if status.State != ChannelHealthStateFailed { + t.Fatalf("expected failed state, got %q", status.State) + } + if status.FailureKind != ChannelFailureKindAuth { + t.Fatalf("expected auth failure kind, got %q", status.FailureKind) + } + if status.Remediation == nil { + t.Fatal("expected remediation metadata") + } + if status.Remediation.Code != ChannelRemediationCodeOpenCredentials { + t.Fatalf("expected open_credentials remediation, got %q", status.Remediation.Code) + } + if status.ConsecutiveFailures != 1 { + t.Fatalf("expected consecutive failures to be 1, got %d", status.ConsecutiveFailures) + } +} + +func TestClassifyChannelErrorSanitizesSensitiveDetails(t *testing.T) { + info := ClassifyChannelError(errors.New(`telego: getMe: api: 401 "Unauthorized" token=123456:ABC host=api.telegram.org`)) + if info.Detail == "" { + t.Fatal("expected sanitized detail") + } + for _, secret := range []string{"123456:ABC", "api.telegram.org", "Unauthorized"} { + if contains := strings.Contains(info.Detail, secret); contains { + t.Fatalf("expected detail to omit %q, got %q", secret, info.Detail) + } + } +} + +func TestManagerStartAllPromotesHealthyChannels(t *testing.T) { + mgr := NewManager(bus.New()) + channel := newFakeHealthChannel("telegram-main") + mgr.RegisterChannel("telegram-main", channel) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := mgr.StartAll(ctx); err != nil { + t.Fatalf("StartAll returned error: %v", err) + } + + raw := mgr.GetStatus()["telegram-main"] + status, ok := raw.(ChannelHealth) + if !ok { + t.Fatalf("expected ChannelHealth entry, got %T", raw) + } + if !status.Running { + t.Fatal("expected running=true") + } + if status.State != ChannelHealthStateHealthy { + t.Fatalf("expected healthy state, got %q", status.State) + } + if status.LastHealthyAt.IsZero() { + t.Fatal("expected last healthy timestamp to be set") + } + if status.Remediation != nil { + t.Fatalf("expected no remediation for healthy channel, got %+v", status.Remediation) + } +} + +func TestManagerStartAllCapturesStartupFailures(t *testing.T) { + mgr := NewManager(bus.New()) + channel := newFakeHealthChannel("telegram-main") + channel.startErr = errors.New(`telego: getUpdates: api: 401 "Unauthorized"`) + mgr.RegisterChannel("telegram-main", channel) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := mgr.StartAll(ctx); err != nil { + t.Fatalf("StartAll returned error: %v", err) + } + + raw := mgr.GetStatus()["telegram-main"] + status, ok := raw.(ChannelHealth) + if !ok { + t.Fatalf("expected ChannelHealth entry, got %T", raw) + } + if status.State != ChannelHealthStateFailed { + t.Fatalf("expected failed state, got %q", status.State) + } + if status.FailureKind != ChannelFailureKindAuth { + t.Fatalf("expected auth failure kind, got %q", status.FailureKind) + } + if status.FailureCount < 1 { + t.Fatalf("expected failure count to increment, got %d", status.FailureCount) + } + if status.Remediation == nil { + t.Fatal("expected remediation metadata") + } + if status.ConsecutiveFailures != 1 { + t.Fatalf("expected consecutive failures to be 1, got %d", status.ConsecutiveFailures) + } +} + +func TestManagerStartAllDoesNotDoubleCountSelfReportedStartupFailure(t *testing.T) { + mgr := NewManager(bus.New()) + channel := newFakeHealthChannel("telegram-main") + channel.startErr = errors.New(`telego: getUpdates: api: 401 "Unauthorized"`) + channel.selfMarkFailure = true + mgr.RegisterChannel("telegram-main", channel) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := mgr.StartAll(ctx); err != nil { + t.Fatalf("StartAll returned error: %v", err) + } + + status := mgr.GetStatus()["telegram-main"].(ChannelHealth) + if status.FailureCount != 1 { + t.Fatalf("expected one startup failure to be recorded, got %d", status.FailureCount) + } + if status.ConsecutiveFailures != 1 { + t.Fatalf("expected one consecutive failure, got %d", status.ConsecutiveFailures) + } +} + +func TestInstanceLoaderAutoStartPreservesRegisteredFailureState(t *testing.T) { + msgBus := bus.New() + mgr := NewManager(msgBus) + loader := NewInstanceLoader(nil, nil, mgr, msgBus, nil) + startErr := errors.New(`telego: getUpdates: api: 401 "Unauthorized"`) + + loader.RegisterFactory(TypeTelegram, func(name string, _, _ json.RawMessage, _ *bus.MessageBus, _ store.PairingStore) (Channel, error) { + ch := newLoaderHealthChannel(name, TypeTelegram) + ch.startErr = startErr + return ch, nil + }) + + loader.mu.Lock() + err := loader.loadInstance(context.Background(), store.ChannelInstanceData{ + Name: "telegram-auto", + ChannelType: TypeTelegram, + }, true) + loader.mu.Unlock() + if err != nil { + t.Fatalf("loadInstance returned error: %v", err) + } + + status := mgr.GetStatus()["telegram-auto"].(ChannelHealth) + if status.State != ChannelHealthStateFailed { + t.Fatalf("expected failed state, got %q", status.State) + } + if status.FailureKind != ChannelFailureKindAuth { + t.Fatalf("expected auth failure kind, got %q", status.FailureKind) + } + if status.Remediation == nil { + t.Fatal("expected remediation metadata") + } +} + +func TestInstanceLoaderAutoStartDoesNotDoubleCountSelfReportedFailure(t *testing.T) { + msgBus := bus.New() + mgr := NewManager(msgBus) + loader := NewInstanceLoader(nil, nil, mgr, msgBus, nil) + + loader.RegisterFactory(TypeTelegram, func(name string, _, _ json.RawMessage, _ *bus.MessageBus, _ store.PairingStore) (Channel, error) { + ch := newLoaderHealthChannel(name, TypeTelegram) + ch.startErr = errors.New(`telego: getUpdates: api: 401 "Unauthorized"`) + ch.selfMarkFailure = true + return ch, nil + }) + + loader.mu.Lock() + err := loader.loadInstance(context.Background(), store.ChannelInstanceData{ + Name: "telegram-auto-self", + ChannelType: TypeTelegram, + }, true) + loader.mu.Unlock() + if err != nil { + t.Fatalf("loadInstance returned error: %v", err) + } + + status := mgr.GetStatus()["telegram-auto-self"].(ChannelHealth) + if status.FailureCount != 1 { + t.Fatalf("expected one startup failure to be recorded, got %d", status.FailureCount) + } + if status.ConsecutiveFailures != 1 { + t.Fatalf("expected one consecutive failure, got %d", status.ConsecutiveFailures) + } +} + +func TestInstanceLoaderTracksPreRegistrationFailuresForCleanup(t *testing.T) { + msgBus := bus.New() + mgr := NewManager(msgBus) + loader := NewInstanceLoader(nil, nil, mgr, msgBus, nil) + + loader.RegisterFactory(TypeTelegram, func(string, json.RawMessage, json.RawMessage, *bus.MessageBus, store.PairingStore) (Channel, error) { + return nil, errors.New("token is required: 123456:ABC") + }) + + loader.mu.Lock() + err := loader.loadInstance(context.Background(), store.ChannelInstanceData{ + Name: "telegram-failed", + ChannelType: TypeTelegram, + }, false) + loader.mu.Unlock() + if err == nil { + t.Fatal("expected factory failure") + } + + if _, ok := loader.LoadedNames()["telegram-failed"]; !ok { + t.Fatal("expected failed instance to remain tracked for cleanup") + } + + status := mgr.GetStatus()["telegram-failed"].(ChannelHealth) + if status.State != ChannelHealthStateFailed { + t.Fatalf("expected failed state, got %q", status.State) + } + if status.Detail == "" || status.Detail == "token is required: 123456:ABC" { + t.Fatalf("expected sanitized detail, got %q", status.Detail) + } + + loader.Stop(context.Background()) + + if _, ok := loader.LoadedNames()["telegram-failed"]; ok { + t.Fatal("expected cleanup to clear loader tracking") + } + if _, ok := mgr.GetStatus()["telegram-failed"]; ok { + t.Fatal("expected cleanup to remove failed status entry") + } +} + +func TestInstanceLoaderCleansEarlyExitFailuresOnStop(t *testing.T) { + tests := []struct { + name string + channelType string + configure func(*InstanceLoader) + wantSummary string + wantKind ChannelFailureKind + }{ + { + name: "unsupported-channel", + channelType: "unsupported", + wantSummary: "Unsupported channel type", + wantKind: ChannelFailureKindConfig, + }, + { + name: "telegram-missing-creds", + channelType: TypeTelegram, + configure: func(loader *InstanceLoader) { + loader.RegisterFactory(TypeTelegram, func(string, json.RawMessage, json.RawMessage, *bus.MessageBus, store.PairingStore) (Channel, error) { + return nil, nil + }) + }, + wantSummary: "Missing credentials", + wantKind: ChannelFailureKindConfig, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + msgBus := bus.New() + mgr := NewManager(msgBus) + loader := NewInstanceLoader(nil, nil, mgr, msgBus, nil) + if tc.configure != nil { + tc.configure(loader) + } + + loader.mu.Lock() + err := loader.loadInstance(context.Background(), store.ChannelInstanceData{ + Name: tc.name, + ChannelType: tc.channelType, + }, false) + loader.mu.Unlock() + if err != nil { + t.Fatalf("loadInstance returned error: %v", err) + } + + if _, ok := loader.LoadedNames()[tc.name]; !ok { + t.Fatal("expected failed instance to remain tracked for cleanup") + } + + status := mgr.GetStatus()[tc.name].(ChannelHealth) + if status.State != ChannelHealthStateFailed { + t.Fatalf("expected failed state, got %q", status.State) + } + if status.Summary != tc.wantSummary { + t.Fatalf("expected summary %q, got %q", tc.wantSummary, status.Summary) + } + if status.FailureKind != tc.wantKind { + t.Fatalf("expected failure kind %q, got %q", tc.wantKind, status.FailureKind) + } + + loader.Stop(context.Background()) + + if _, ok := loader.LoadedNames()[tc.name]; ok { + t.Fatal("expected cleanup to clear loader tracking") + } + if _, ok := mgr.GetStatus()[tc.name]; ok { + t.Fatal("expected cleanup to remove failed status entry") + } + }) + } +} + +func TestBuildChannelRemediationUsesRealOperatorTargets(t *testing.T) { + t.Helper() + + cases := []struct { + name string + snapshot ChannelHealth + wantCode ChannelRemediationCode + wantTarget ChannelRemediationTarget + }{ + { + name: "telegram auth opens credentials", + snapshot: NewFailedChannelHealthForType( + TypeTelegram, + "", + errors.New(`telego: getMe: api: 401 "Unauthorized"`), + ), + wantCode: ChannelRemediationCodeOpenCredentials, + wantTarget: ChannelRemediationTargetCredentials, + }, + { + name: "zalo personal auth reauthenticates", + snapshot: NewFailedChannelHealthForType( + TypeZaloPersonal, + "", + errors.New(`session expired: unauthorized`), + ), + wantCode: ChannelRemediationCodeReauth, + wantTarget: ChannelRemediationTargetReauth, + }, + { + name: "missing credentials opens credentials", + snapshot: NewChannelHealthForType( + TypeSlack, + ChannelHealthStateFailed, + "Missing credentials", + "Set channels.slack.bot_token in config.", + ChannelFailureKindConfig, + false, + ), + wantCode: ChannelRemediationCodeOpenCredentials, + wantTarget: ChannelRemediationTargetCredentials, + }, + { + name: "invalid proxy opens advanced", + snapshot: NewChannelHealthForType( + TypeTelegram, + ChannelHealthStateFailed, + "Configuration is invalid", + "invalid proxy URL", + ChannelFailureKindConfig, + false, + ), + wantCode: ChannelRemediationCodeOpenAdvanced, + wantTarget: ChannelRemediationTargetAdvanced, + }, + { + name: "network timeout checks network", + snapshot: NewFailedChannelHealthForType( + TypeTelegram, + "", + errors.New(`dial tcp 127.0.0.1:443: i/o timeout`), + ), + wantCode: ChannelRemediationCodeCheckNetwork, + wantTarget: ChannelRemediationTargetDetails, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.snapshot = mergeChannelHealth(ChannelHealth{}, tc.snapshot) + if tc.snapshot.Remediation == nil { + t.Fatal("expected remediation") + } + if tc.snapshot.Remediation.Code != tc.wantCode { + t.Fatalf("expected remediation code %q, got %q", tc.wantCode, tc.snapshot.Remediation.Code) + } + if tc.snapshot.Remediation.Target != tc.wantTarget { + t.Fatalf("expected remediation target %q, got %q", tc.wantTarget, tc.snapshot.Remediation.Target) + } + }) + } +} + +func TestMergeChannelHealthTracksFailureTimelineAndRecovery(t *testing.T) { + firstFailedAt := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + secondFailedAt := firstFailedAt.Add(2 * time.Minute) + recoveredAt := secondFailedAt.Add(3 * time.Minute) + thirdFailedAt := recoveredAt.Add(4 * time.Minute) + + first := NewFailedChannelHealthForType(TypeTelegram, "", errors.New(`telego: getUpdates: api: 401 "Unauthorized"`)) + first.CheckedAt = firstFailedAt + first = mergeChannelHealth(ChannelHealth{}, first) + + second := NewFailedChannelHealthForType(TypeTelegram, "", errors.New(`telego: getUpdates: api: 401 "Unauthorized"`)) + second.CheckedAt = secondFailedAt + second = mergeChannelHealth(first, second) + + if second.FailureCount != 2 { + t.Fatalf("expected cumulative failure count 2, got %d", second.FailureCount) + } + if second.ConsecutiveFailures != 2 { + t.Fatalf("expected consecutive failures 2, got %d", second.ConsecutiveFailures) + } + if !second.FirstFailedAt.Equal(firstFailedAt) { + t.Fatalf("expected first failed at %v, got %v", firstFailedAt, second.FirstFailedAt) + } + if !second.LastFailedAt.Equal(secondFailedAt) { + t.Fatalf("expected last failed at %v, got %v", secondFailedAt, second.LastFailedAt) + } + + recovered := NewChannelHealthForType(TypeTelegram, ChannelHealthStateHealthy, "Connected", "", ChannelFailureKindUnknown, false) + recovered.CheckedAt = recoveredAt + recovered = mergeChannelHealth(second, recovered) + + if recovered.ConsecutiveFailures != 0 { + t.Fatalf("expected consecutive failures reset, got %d", recovered.ConsecutiveFailures) + } + if recovered.Remediation != nil { + t.Fatalf("expected remediation to clear after recovery, got %+v", recovered.Remediation) + } + if !recovered.LastHealthyAt.Equal(recoveredAt) { + t.Fatalf("expected last healthy at %v, got %v", recoveredAt, recovered.LastHealthyAt) + } + if recovered.FailureCount != 2 { + t.Fatalf("expected cumulative failure count to be preserved, got %d", recovered.FailureCount) + } + + third := NewFailedChannelHealthForType(TypeTelegram, "", errors.New(`telego: getUpdates: api: 401 "Unauthorized"`)) + third.CheckedAt = thirdFailedAt + third = mergeChannelHealth(recovered, third) + + if third.ConsecutiveFailures != 1 { + t.Fatalf("expected new failure streak to start at 1, got %d", third.ConsecutiveFailures) + } + if !third.FirstFailedAt.Equal(thirdFailedAt) { + t.Fatalf("expected first failed timestamp to reset to latest incident start, got %v", third.FirstFailedAt) + } + if third.FailureCount != 3 { + t.Fatalf("expected cumulative failure count 3, got %d", third.FailureCount) + } +} diff --git a/internal/channels/instance_loader.go b/internal/channels/instance_loader.go index e45579a3..025d7c68 100644 --- a/internal/channels/instance_loader.go +++ b/internal/channels/instance_loader.go @@ -204,8 +204,18 @@ func (l *InstanceLoader) LoadedNames() map[string]struct{} { // If autoStart is true, the channel is started immediately (used by Reload). // If false, the caller is responsible for starting (used by LoadAll, where StartAll handles it). func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelInstanceData, autoStart bool) error { + l.loaded[inst.Name] = struct{}{} + factory, ok := l.factories[inst.ChannelType] if !ok { + l.manager.RecordHealth(inst.Name, NewChannelHealthForType( + inst.ChannelType, + ChannelHealthStateFailed, + "Unsupported channel type", + fmt.Sprintf("No channel factory is registered for %q", inst.ChannelType), + ChannelFailureKindConfig, + false, + )) slog.Warn("no factory for channel type", "type", inst.ChannelType, "name", inst.Name) return nil } @@ -216,9 +226,18 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns ch, err := factory(inst.Name, inst.Credentials, cfg, l.msgBus, l.pairingSvc) if err != nil { + l.manager.RecordFailureForType(inst.Name, inst.ChannelType, "", err) return err } if ch == nil { + l.manager.RecordHealth(inst.Name, NewChannelHealthForType( + inst.ChannelType, + ChannelHealthStateFailed, + "Missing credentials", + "Channel instance is enabled but required credentials are incomplete.", + ChannelFailureKindConfig, + false, + )) slog.Info("channel instance not ready (missing credentials)", "name", inst.Name, "type", inst.ChannelType) return nil } @@ -231,6 +250,7 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns var err error ag, err = l.agentStore.GetByID(instCtx, inst.AgentID) if err != nil { + l.manager.RecordFailureForType(inst.Name, inst.ChannelType, "", fmt.Errorf("agent %s not found for channel %s: %w", inst.AgentID, inst.Name, err)) return fmt.Errorf("agent %s not found for channel %s: %w", inst.AgentID, inst.Name, err) } base.SetAgentID(ag.AgentKey) @@ -303,13 +323,15 @@ func (l *InstanceLoader) loadInstance(ctx context.Context, inst store.ChannelIns } } l.manager.RegisterChannel(inst.Name, ch) - l.loaded[inst.Name] = struct{}{} // Start the channel if requested (Reload path). LoadAll defers to StartAll. if autoStart { if err := ch.Start(ctx); err != nil { + l.manager.recordChannelStartFailure(inst.Name, ch, "", err) slog.Error("channel instance start failed", "name", inst.Name, "error", err) // Still registered — will show as not running. + } else { + l.manager.RecordHealth(inst.Name, snapshotChannelHealth(ch)) } } diff --git a/internal/channels/manager.go b/internal/channels/manager.go index dfc47b91..1c4c4133 100644 --- a/internal/channels/manager.go +++ b/internal/channels/manager.go @@ -49,6 +49,7 @@ type RunContext struct { // and routing outbound messages to the correct channel. type Manager struct { channels map[string]Channel + health map[string]ChannelHealth bus *bus.MessageBus runs sync.Map // runID string → *RunContext dispatchTask *asyncTask @@ -65,6 +66,7 @@ type asyncTask struct { func NewManager(msgBus *bus.MessageBus) *Manager { return &Manager{ channels: make(map[string]Channel), + health: make(map[string]ChannelHealth), bus: msgBus, } } @@ -90,9 +92,16 @@ func (m *Manager) StartAll(ctx context.Context) error { for name, channel := range m.channels { slog.Info("starting channel", "channel", name) - if err := channel.Start(ctx); err != nil { - slog.Error("failed to start channel", "channel", name, "error", err) + if hc, ok := channel.(interface{ MarkStarting(string) }); ok { + hc.MarkStarting("Starting") } + m.syncChannelHealthLocked(name, channel) + if err := channel.Start(ctx); err != nil { + m.recordChannelStartFailureLocked(name, channel, "", err) + slog.Error("failed to start channel", "channel", name, "error", err) + continue + } + m.syncChannelHealthLocked(name, channel) } slog.Info("all channels started") @@ -114,8 +123,14 @@ func (m *Manager) StopAll(ctx context.Context) error { for name, channel := range m.channels { slog.Info("stopping channel", "channel", name) if err := channel.Stop(ctx); err != nil { + m.recordHealthLocked(name, NewFailedChannelHealth("Failed to stop channel", err)) slog.Error("error stopping channel", "channel", name, "error", err) + continue } + if hc, ok := channel.(interface{ MarkStopped(string) }); ok { + hc.MarkStopped("Stopped") + } + m.syncChannelHealthLocked(name, channel) } slog.Info("all channels stopped") @@ -135,12 +150,12 @@ func (m *Manager) GetStatus() map[string]any { m.mu.RLock() defer m.mu.RUnlock() - status := make(map[string]any) + status := make(map[string]any, len(m.health)+len(m.channels)) + for name, snapshot := range m.health { + status[name] = snapshot + } for name, channel := range m.channels { - status[name] = map[string]any{ - "enabled": true, - "running": channel.IsRunning(), - } + status[name] = snapshotChannelHealth(channel) } return status } @@ -168,6 +183,75 @@ func (m *Manager) RegisterChannel(name string, channel Channel) { } } m.channels[name] = channel + if hc, ok := channel.(interface{ MarkRegistered(string) }); ok { + hc.MarkRegistered("Configured") + } + m.syncChannelHealthLocked(name, channel) +} + +// RecordHealth stores runtime health for an instance, including failures before registration. +func (m *Manager) RecordHealth(name string, snapshot ChannelHealth) { + m.mu.Lock() + defer m.mu.Unlock() + m.recordHealthLocked(name, snapshot) +} + +// RecordFailure stores a classified failure snapshot for an instance. +func (m *Manager) RecordFailure(name, summary string, err error) { + m.RecordHealth(name, NewFailedChannelHealth(summary, err)) +} + +// RecordFailureForType stores a classified failure snapshot for an instance before registration exists. +func (m *Manager) RecordFailureForType(name, channelType, summary string, err error) { + m.RecordHealth(name, NewFailedChannelHealthForType(channelType, summary, err)) +} + +func (m *Manager) recordChannelStartFailure(name string, channel Channel, summary string, err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.recordChannelStartFailureLocked(name, channel, summary, err) +} + +func (m *Manager) recordChannelStartFailureLocked(name string, channel Channel, summary string, err error) { + info := ClassifyChannelError(err) + if summary == "" { + summary = info.Summary + } + + current := snapshotChannelHealth(channel) + if isFailureState(current.State) { + if current.ChannelType == "" { + current.ChannelType = channel.Type() + } + if current.Summary == "" { + current.Summary = summary + } + if current.Detail == "" { + current.Detail = info.Detail + } + if current.FailureKind == "" { + current.FailureKind = info.Kind + } + m.recordHealthLocked(name, current) + return + } + + if hc, ok := channel.(interface { + MarkFailed(string, string, ChannelFailureKind, bool) + }); ok { + hc.MarkFailed(summary, info.Detail, info.Kind, info.Retryable) + m.syncChannelHealthLocked(name, channel) + return + } + + m.recordHealthLocked(name, NewChannelHealthForType( + channel.Type(), + ChannelHealthStateFailed, + summary, + info.Detail, + info.Kind, + info.Retryable, + )) } // SetContactCollector sets the contact collector for all current and future channels. @@ -229,4 +313,48 @@ func (m *Manager) UnregisterChannel(name string) { m.mu.Lock() defer m.mu.Unlock() delete(m.channels, name) + delete(m.health, name) +} + +func (m *Manager) recordHealthLocked(name string, snapshot ChannelHealth) { + prev := m.health[name] + if snapshot.ChannelType == "" { + switch { + case prev.ChannelType != "": + snapshot.ChannelType = prev.ChannelType + case m.channels[name] != nil: + snapshot.ChannelType = m.channels[name].Type() + default: + snapshot.ChannelType = name + } + } + m.health[name] = mergeChannelHealth(prev, snapshot) +} + +func (m *Manager) syncChannelHealthLocked(name string, channel Channel) { + m.recordHealthLocked(name, snapshotChannelHealth(channel)) +} + +func snapshotChannelHealth(channel Channel) ChannelHealth { + if reporter, ok := channel.(interface{ HealthSnapshot() ChannelHealth }); ok { + snapshot := reporter.HealthSnapshot() + snapshot.ChannelType = channel.Type() + snapshot.Enabled = true + snapshot.Running = channel.IsRunning() + return snapshot + } + + state := ChannelHealthStateStopped + summary := "Stopped" + if channel.IsRunning() { + state = ChannelHealthStateHealthy + summary = "Connected" + } + return ChannelHealth{ + ChannelType: channel.Type(), + Enabled: true, + Running: channel.IsRunning(), + State: state, + Summary: summary, + } } diff --git a/internal/channels/telegram/channel.go b/internal/channels/telegram/channel.go index 0296535e..24cd98f9 100644 --- a/internal/channels/telegram/channel.go +++ b/internal/channels/telegram/channel.go @@ -23,32 +23,32 @@ import ( // Channel connects to Telegram via the Bot API using long polling. type Channel struct { *channels.BaseChannel - bot *telego.Bot - config config.TelegramConfig - httpClient *http.Client - transport *http.Transport - ipv4Once sync.Once // guards enableIPv4Only to prevent data race - pairingService store.PairingStore - agentStore store.AgentStore // for agent key lookup (nil if not configured) - configPermStore store.ConfigPermissionStore // for group file writer management (nil if not configured) - teamStore store.TeamStore // for /tasks, /task_detail commands (nil if not configured) - subagentTaskStore store.SubagentTaskStore // for /subagents, /subagent commands (nil if not configured) - placeholders sync.Map // localKey string → messageID int - stopThinking sync.Map // localKey string → *thinkingCancel - typingCtrls sync.Map // localKey string → *typing.Controller - reactions sync.Map // localKey string → *StatusReactionController - pairingReplySent sync.Map // userID string → time.Time (debounce pairing replies) - threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing) - approvedGroups sync.Map // chatIDStr string → true (cached group pairing approval) - groupHistory *channels.PendingHistory - historyLimit int - requireMention bool - mentionMode string // "strict" (default) or "yield" - pollCancel context.CancelFunc // cancels the long polling context - pollDone chan struct{} // closed when polling goroutine exits - handlerWg sync.WaitGroup // tracks in-flight handler goroutines for graceful shutdown - handlerSem chan struct{} // bounded semaphore for concurrent handler goroutines - pendingDraftID sync.Map // localKey string → int (draftID) + bot *telego.Bot + config config.TelegramConfig + httpClient *http.Client + transport *http.Transport + ipv4Once sync.Once // guards enableIPv4Only to prevent data race + pairingService store.PairingStore + agentStore store.AgentStore // for agent key lookup (nil if not configured) + configPermStore store.ConfigPermissionStore // for group file writer management (nil if not configured) + teamStore store.TeamStore // for /tasks, /task_detail commands (nil if not configured) + subagentTaskStore store.SubagentTaskStore // for /subagents, /subagent commands (nil if not configured) + placeholders sync.Map // localKey string → messageID int + stopThinking sync.Map // localKey string → *thinkingCancel + typingCtrls sync.Map // localKey string → *typing.Controller + reactions sync.Map // localKey string → *StatusReactionController + pairingReplySent sync.Map // userID string → time.Time (debounce pairing replies) + threadIDs sync.Map // localKey string → messageThreadID int (for forum topic routing) + approvedGroups sync.Map // chatIDStr string → true (cached group pairing approval) + groupHistory *channels.PendingHistory + historyLimit int + requireMention bool + mentionMode string // "strict" (default) or "yield" + pollCancel context.CancelFunc // cancels the long polling context + pollDone chan struct{} // closed when polling goroutine exits + handlerWg sync.WaitGroup // tracks in-flight handler goroutines for graceful shutdown + handlerSem chan struct{} // bounded semaphore for concurrent handler goroutines + pendingDraftID sync.Map // localKey string → int (draftID) } type thinkingCancel struct { @@ -170,6 +170,18 @@ func New(cfg config.TelegramConfig, msgBus *bus.MessageBus, pairingSvc store.Pai // Start begins long polling for Telegram updates. func (c *Channel) Start(ctx context.Context) error { slog.Info("starting telegram bot (polling mode)") + c.MarkStarting("Validating Telegram bot") + + probeCtx, probeCancel := context.WithTimeout(ctx, probeOverallTimeout) + me, err := c.bot.GetMe(probeCtx) + probeCancel() + if err != nil { + return fmt.Errorf("validate telegram bot: %w", err) + } + username := "" + if me != nil { + username = me.Username + } // Create a cancellable context for the polling goroutine. // Stop() cancels this context to cleanly shut down long polling. @@ -192,18 +204,21 @@ func (c *Channel) Start(ctx context.Context) error { } c.SetRunning(true) + c.MarkHealthy(connectedSummary(username)) c.groupHistory.StartFlusher() c.handlerSem = make(chan struct{}, 20) // limit concurrent message handlers - slog.Info("telegram bot connected", "username", c.bot.Username()) + slog.Info("telegram bot connected", "username", username) // Register bot menu commands with retry. go func() { commands := DefaultMenuCommands() syncCtx, cancel := context.WithTimeout(pollCtx, probeOverallTimeout) defer cancel() + var lastErr error for attempt := 1; attempt <= 3; attempt++ { if err := c.SyncMenuCommands(syncCtx, commands); err != nil { + lastErr = err slog.Warn("failed to sync telegram menu commands", "error", err, "attempt", attempt) if attempt < 3 { select { @@ -217,6 +232,9 @@ func (c *Channel) Start(ctx context.Context) error { return } } + if lastErr != nil { + slog.Warn("telegram menu commands remain unsynced", "error", lastErr) + } }() go func() { @@ -227,6 +245,9 @@ func (c *Channel) Start(ctx context.Context) error { return case update, ok := <-updates: if !ok { + if pollCtx.Err() == nil { + c.MarkFailed("Polling stopped unexpectedly", "Telegram updates channel closed unexpectedly.", channels.ChannelFailureKindNetwork, true) + } slog.Info("telegram updates channel closed") return } @@ -324,6 +345,7 @@ func (c *Channel) SetPendingHistoryTenantID(id uuid.UUID) { c.groupHistory.SetTe func (c *Channel) Stop(_ context.Context) error { slog.Info("stopping telegram bot") c.SetRunning(false) + c.MarkStopped("Stopped") c.groupHistory.StopFlusher() if c.pollCancel != nil { @@ -356,6 +378,13 @@ func (c *Channel) Stop(_ context.Context) error { return nil } +func connectedSummary(username string) string { + if username == "" { + return "Connected" + } + return fmt.Sprintf("Connected as @%s", username) +} + // applyIPv4Dialer forces a transport to use IPv4 only by overriding DialContext. func applyIPv4Dialer(t *http.Transport) { dialer := &net.Dialer{ diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx index d3b69cd4..8b6f8177 100644 --- a/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelCard.tsx @@ -1,13 +1,16 @@ -import { useTranslation } from 'react-i18next' -import { Switch } from '../../common/Switch' -import type { ChannelInstanceData, ChannelStatus } from '../../../types/channel' +import { useTranslation } from "react-i18next"; +import { Switch } from "../../common/Switch"; +import type { + ChannelInstanceData, + ChannelStatus, +} from "../../../types/channel"; interface ChannelCardProps { - instance: ChannelInstanceData - status: ChannelStatus | null - agentName: string - onToggleEnabled: (enabled: boolean) => void - onClick: () => void + instance: ChannelInstanceData; + status: ChannelStatus | null; + agentName: string; + onToggleEnabled: (enabled: boolean) => void; + onClick: () => void; } function TelegramIcon() { @@ -15,7 +18,7 @@ function TelegramIcon() { - ) + ); } function DiscordIcon() { @@ -23,23 +26,55 @@ function DiscordIcon() { - ) + ); } -export function ChannelCard({ instance, status, agentName, onToggleEnabled, onClick }: ChannelCardProps) { - const { t } = useTranslation('channels') - const isTelegram = instance.channel_type === 'telegram' +export function ChannelCard({ + instance, + status, + agentName, + onToggleEnabled, + onClick, +}: ChannelCardProps) { + 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 && status?.running) { - dotColor = 'bg-emerald-500' - statusText = t('status.running') - } else if (instance.enabled) { - dotColor = 'bg-amber-500' - statusText = t('status.stopped') + 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 summaryHint = + instance.enabled && status?.summary && status.state !== "healthy" + ? status.summary + : null; return (
{statusText} · - {agentName} + + {agentName} +
+ {summaryHint && ( +
+ {summaryHint} +
+ )} {/* Enable toggle */} @@ -79,5 +121,5 @@ export function ChannelCard({ instance, status, agentName, onToggleEnabled, onCl - ) + ); } diff --git a/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx b/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx index ca1baa15..1ef7f1aa 100644 --- a/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx +++ b/ui/desktop/frontend/src/components/settings/channels/ChannelDetailPanel.tsx @@ -1,71 +1,156 @@ -import { useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useChannelDetail } from '../../../hooks/use-channel-detail' -import { useAgentCrud } from '../../../hooks/use-agent-crud' -import { ChannelGeneralTab } from './ChannelGeneralTab' -import { ChannelCredentialsTab } from './ChannelCredentialsTab' -import { ChannelManagersTab } from './ChannelManagersTab' -import { ChannelAdvancedTab } from './ChannelAdvancedTab' -import type { ChannelStatus } from '../../../types/channel' +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useChannelDetail } from "../../../hooks/use-channel-detail"; +import { useAgentCrud } from "../../../hooks/use-agent-crud"; +import { ChannelGeneralTab } from "./ChannelGeneralTab"; +import { ChannelCredentialsTab } from "./ChannelCredentialsTab"; +import { ChannelManagersTab } from "./ChannelManagersTab"; +import { ChannelAdvancedTab } from "./ChannelAdvancedTab"; +import type { ChannelStatus } from "../../../types/channel"; interface ChannelDetailPanelProps { - instanceId: string - status: ChannelStatus | null - onBack: () => void - onDelete: () => void + instanceId: string; + status: ChannelStatus | null; + onBack: () => void; + onDelete: () => void; } -const TABS = ['general', 'credentials', 'managers', 'advanced'] as const -type TabKey = (typeof TABS)[number] +const TABS = ["general", "credentials", "managers", "advanced"] as const; +type TabKey = (typeof TABS)[number]; -export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: ChannelDetailPanelProps) { - const { t } = useTranslation('channels') - const { instance, loading, updateInstance, listManagerGroups, listManagers, addManager, removeManager, listContacts } = useChannelDetail(instanceId) - const { agents } = useAgentCrud() - const [activeTab, setActiveTab] = useState('general') +export function ChannelDetailPanel({ + instanceId, + status, + onBack, + onDelete, +}: ChannelDetailPanelProps) { + const { t } = useTranslation("channels"); + const { + instance, + loading, + updateInstance, + listManagerGroups, + listManagers, + addManager, + removeManager, + listContacts, + } = useChannelDetail(instanceId); + const { agents } = useAgentCrud(); + const [activeTab, setActiveTab] = useState("general"); if (loading || !instance) { return (
-
- {[1, 2, 3].map((i) =>
)} + {[1, 2, 3].map((i) => ( +
+ ))}
- ) + ); } // Status - let dotColor = 'bg-gray-400' - let statusText = t('status.disabled') - if (instance.enabled && status?.running) { dotColor = 'bg-emerald-500'; statusText = t('status.running') } - else if (instance.enabled) { dotColor = 'bg-amber-500'; statusText = t('status.stopped') } + 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 agentName = (() => { - const a = agents.find((a) => a.id === instance.agent_id) - return a?.display_name || a?.agent_key || instance.agent_id.slice(0, 8) - })() + const a = agents.find((a) => a.id === instance.agent_id); + return a?.display_name || a?.agent_key || instance.agent_id.slice(0, 8); + })(); return (
{/* Header */}
-
- - {instance.display_name || instance.name} - - {t(`channelTypes.${instance.channel_type}`)} - - - {statusText} - · {agentName} +
+
+ + + {instance.display_name || instance.name} + + + {t(`channelTypes.${instance.channel_type}`)} + + + + {statusText} + + + · {agentName} + +
+ {(status?.summary || status?.detail) && ( +
+ {status?.summary && ( +
+ {status.summary} +
+ )} + {status?.detail && ( +
+ {status.detail} +
+ )} + {status?.checked_at && ( +
+ {t("detail.lastChecked", { + defaultValue: "Last checked: {{value}}", + value: new Date(status.checked_at).toLocaleString(), + })} +
+ )} +
+ )}
-
@@ -77,8 +162,8 @@ export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: Cha onClick={() => setActiveTab(tab)} className={`px-3 py-1.5 text-xs transition-colors cursor-pointer ${ activeTab === tab - ? 'text-accent font-medium border-b-2 border-accent -mb-px' - : 'text-text-muted hover:text-text-primary' + ? "text-accent font-medium border-b-2 border-accent -mb-px" + : "text-text-muted hover:text-text-primary" }`} > {t(`detail.tabs.${tab}`)} @@ -88,9 +173,20 @@ export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: Cha {/* Tab content */}
- {activeTab === 'general' && } - {activeTab === 'credentials' && } - {activeTab === 'managers' && ( + {activeTab === "general" && ( + + )} + {activeTab === "credentials" && ( + + )} + {activeTab === "managers" && ( )} - {activeTab === 'advanced' && } + {activeTab === "advanced" && ( + + )}
- ) + ); } diff --git a/ui/desktop/frontend/src/i18n/locales/en/channels.json b/ui/desktop/frontend/src/i18n/locales/en/channels.json index d8dbd0af..4d2c86fd 100644 --- a/ui/desktop/frontend/src/i18n/locales/en/channels.json +++ b/ui/desktop/frontend/src/i18n/locales/en/channels.json @@ -8,7 +8,11 @@ "status": { "running": "Running", "stopped": "Stopped", - "disabled": "Disabled" + "disabled": "Disabled", + "degraded": "Degraded", + "starting": "Starting", + "registered": "Configured", + "failed": "Failed" }, "channelTypes": { "telegram": "Telegram", @@ -38,6 +42,7 @@ }, "detail": { "back": "Back", + "lastChecked": "Last checked: {{value}}", "tabs": { "general": "General", "credentials": "Credentials", diff --git a/ui/desktop/frontend/src/types/channel.ts b/ui/desktop/frontend/src/types/channel.ts index 309c6e4d..d108787d 100644 --- a/ui/desktop/frontend/src/types/channel.ts +++ b/ui/desktop/frontend/src/types/channel.ts @@ -1,73 +1,96 @@ // Channel instance data returned by GET /v1/channels/instances export interface ChannelInstanceData { - id: string - name: string - display_name: string - channel_type: string // "telegram" | "discord" - agent_id: string - credentials: Record - config: Record - enabled: boolean - created_by: string - created_at: string - updated_at: string + id: string; + name: string; + display_name: string; + channel_type: string; // "telegram" | "discord" + agent_id: string; + credentials: Record; + config: Record; + enabled: boolean; + created_by: string; + created_at: string; + updated_at: string; } // Input for creating/updating a channel instance export interface ChannelInstanceInput { - name: string - displayName: string - channelType: string - agentId: string - credentials: Record - config: Record - enabled: boolean + name: string; + displayName: string; + channelType: string; + agentId: string; + credentials: Record; + config: Record; + enabled: boolean; } // Live channel status from WS channels.status export interface ChannelStatus { - enabled: boolean - running: boolean + enabled: boolean; + running: boolean; + state?: + | "registered" + | "starting" + | "healthy" + | "degraded" + | "failed" + | "stopped"; + summary?: string; + detail?: string; + failure_kind?: "auth" | "config" | "network" | "unknown"; + retryable?: boolean; + checked_at?: string; + failure_count?: number; + consecutive_failures?: number; + first_failed_at?: string; + last_failed_at?: string; + last_healthy_at?: string; + remediation?: { + code: "reauth" | "open_credentials" | "open_advanced" | "check_network"; + headline: string; + hint?: string; + target?: "credentials" | "advanced" | "reauth" | "details"; + }; } // Pending pairing request from WS device.pair.list export interface PendingPairing { - code: string - sender_id: string - channel: string - chat_id: string - account_id: string - created_at: number - expires_at: number + code: string; + sender_id: string; + channel: string; + chat_id: string; + account_id: string; + created_at: number; + expires_at: number; } // Approved paired device from WS device.pair.list export interface PairedDevice { - sender_id: string - channel: string - chat_id: string - paired_at: number - paired_by: string + sender_id: string; + channel: string; + chat_id: string; + paired_at: number; + paired_by: string; } // Manager group info from GET /v1/channels/instances/{id}/writers/groups export interface GroupManagerGroupInfo { - group_id: string - writer_count: number + group_id: string; + writer_count: number; } // Manager data from GET /v1/channels/instances/{id}/writers export interface GroupManagerData { - user_id: string - display_name?: string - username?: string + user_id: string; + display_name?: string; + username?: string; } // Contact from GET /v1/contacts export interface ChannelContact { - id: string - sender_id: string - display_name?: string - username?: string - channel_type: string + id: string; + sender_id: string; + display_name?: string; + username?: string; + channel_type: string; } diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index 6934165b..fe33498d 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -20,13 +20,33 @@ }, "status": { "running": "Running", - "stopped": "Stopped" + "stopped": "Stopped", + "checking": "Checking", + "degraded": "Degraded", + "starting": "Starting", + "registered": "Configured", + "failed": "Failed" }, "enabled": "Enabled", "disabled": "Disabled", "actions": { "reauthenticate": "Re-authenticate", - "authenticate": "Authenticate to start channel" + "authenticate": "Authenticate to start channel", + "reauthShort": "Re-auth", + "openCredentials": "Open credentials", + "openAdvanced": "Open advanced", + "inspect": "Inspect issue" + }, + "list": { + "failureStreak": "{{count}} failures in a row", + "openChannelDetail": "Open channel detail for the latest diagnosis", + "nextStep": "Next step" + }, + "failureKind": { + "auth": "Auth", + "config": "Config", + "network": "Network", + "unknown": "Attention" }, "delete": { "title": "Delete Channel Instance", @@ -74,6 +94,8 @@ }, "detail": { "agent": "Agent: {{name}}", + "lastChecked": "Last checked: {{value}}", + "checkedRelative": "Checked {{value}}", "advanced": "Advanced", "advancedTitle": "Advanced Settings", "policies": "Policies", @@ -88,6 +110,10 @@ "behaviorDesc": "Response behavior configuration", "accessControl": "Access Control", "accessControlDesc": "User access restrictions", + "reviewDiagnostics": "Review the latest diagnosis in this channel before changing settings.", + "whatHappened": "What happened", + "recommendedAction": "Recommended action", + "technicalDetail": "Technical detail", "tabs": { "general": "General", "config": "Config", @@ -131,6 +157,16 @@ "saveGroups": "Save Groups", "saving": "Saving..." }, + "timeline": { + "title": "Timeline", + "firstFailed": "First failed", + "lastChecked": "Last checked", + "failures": "Failures", + "failureStreak": "{{count}} in a row", + "failureTotal": "{{count}} total", + "lastHealthy": "Last healthy", + "noData": "No recent channel checks recorded yet." + }, "managers": { "description": "Manage which users have manager permissions in group chats.", "groups": "Groups", @@ -171,44 +207,116 @@ "fieldConfig": { "token": { "label": "Bot Token", "help": "From @BotFather" }, "bot_token": { "label": "Bot Token" }, - "app_token": { "label": "App-Level Token", "help": "App-Level Token with connections:write scope (required for Socket Mode)" }, - "user_token": { "label": "User Token (Optional)", "help": "Optional: User OAuth Token for custom bot identity. Leave empty to use default bot identity." }, + "app_token": { + "label": "App-Level Token", + "help": "App-Level Token with connections:write scope (required for Socket Mode)" + }, + "user_token": { + "label": "User Token (Optional)", + "help": "Optional: User OAuth Token for custom bot identity. Leave empty to use default bot identity." + }, "app_id": { "label": "App ID" }, "app_secret": { "label": "App Secret" }, "encrypt_key": { "label": "Encrypt Key", "help": "For webhook mode" }, - "verification_token": { "label": "Verification Token", "help": "For webhook mode" }, + "verification_token": { + "label": "Verification Token", + "help": "For webhook mode" + }, "webhook_secret": { "label": "Webhook Secret" }, "bridge_url": { "label": "Bridge URL" }, - "api_server": { "label": "API Server URL", "help": "Custom Telegram Bot API server for large file uploads (up to 2GB). Leave empty for default." }, - "proxy": { "label": "HTTP Proxy", "help": "Route bot traffic through an HTTP proxy" }, + "api_server": { + "label": "API Server URL", + "help": "Custom Telegram Bot API server for large file uploads (up to 2GB). Leave empty for default." + }, + "proxy": { + "label": "HTTP Proxy", + "help": "Route bot traffic through an HTTP proxy" + }, "dm_policy": { "label": "DM Policy" }, "group_policy": { "label": "Group Policy" }, - "require_mention": { "label": "Require @mention in groups", "disabledHint": "Multi-bot mode always listens to all messages" }, - "mention_mode": { "label": "Group Response Behavior", "help": "How the bot decides when to respond in groups with multiple bots." }, - "history_limit": { "label": "Group History Limit", "help": "Max pending group messages for context (0 = disabled)" }, - "dm_stream": { "label": "DM Streaming", "help": "Stream response progressively in DMs" }, - "group_stream": { "label": "Group Streaming", "help": "Stream response progressively in groups" }, - "draft_transport": { "label": "Draft Preview", "help": "Use stealth draft preview for answer stream in DMs — no notification per edit (requires DM Streaming)" }, - "reasoning_stream": { "label": "Show Reasoning", "help": "Display AI thinking as a separate message before the answer (requires streaming)" }, + "require_mention": { + "label": "Require @mention in groups", + "disabledHint": "Multi-bot mode always listens to all messages" + }, + "mention_mode": { + "label": "Group Response Behavior", + "help": "How the bot decides when to respond in groups with multiple bots." + }, + "history_limit": { + "label": "Group History Limit", + "help": "Max pending group messages for context (0 = disabled)" + }, + "dm_stream": { + "label": "DM Streaming", + "help": "Stream response progressively in DMs" + }, + "group_stream": { + "label": "Group Streaming", + "help": "Stream response progressively in groups" + }, + "draft_transport": { + "label": "Draft Preview", + "help": "Use stealth draft preview for answer stream in DMs — no notification per edit (requires DM Streaming)" + }, + "reasoning_stream": { + "label": "Show Reasoning", + "help": "Display AI thinking as a separate message before the answer (requires streaming)" + }, "reaction_level": { "label": "Reaction Level" }, "media_max_mb": { "label": "Max Media Size (MB)" }, "link_preview": { "label": "Link Preview" }, "allow_from": { "label": "Allowed Users" }, - "block_reply": { "label": "Block Reply", "help": "Deliver intermediate text during tool iterations" }, + "block_reply": { + "label": "Block Reply", + "help": "Deliver intermediate text during tool iterations" + }, "domain": { "label": "Domain" }, - "connection_mode": { "label": "Connection Mode", "help": "WebSocket needs no public IP — outbound connection only" }, - "webhook_port": { "label": "Webhook Port", "help": "0 = share main gateway port (recommended)" }, - "webhook_path": { "label": "Webhook Path", "help": "Path on main server for Lark events" }, + "connection_mode": { + "label": "Connection Mode", + "help": "WebSocket needs no public IP — outbound connection only" + }, + "webhook_port": { + "label": "Webhook Port", + "help": "0 = share main gateway port (recommended)" + }, + "webhook_path": { + "label": "Webhook Path", + "help": "Path on main server for Lark events" + }, "webhook_url": { "label": "Webhook URL" }, - "topic_session_mode": { "label": "Topic Session Mode", "help": "Use thread root_id for session isolation" }, + "topic_session_mode": { + "label": "Topic Session Mode", + "help": "Use thread root_id for session isolation" + }, "render_mode": { "label": "Render Mode" }, - "text_chunk_limit": { "label": "Text Chunk Limit", "help": "Max characters per message" }, - "group_allow_from": { "label": "Group Allowed Users", "help": "Separate allowlist for group senders" }, - "native_stream": { "label": "Native Streaming (Agents & AI Apps)", "help": "Use Slack's ChatStreamer API for native streaming. Falls back to edit-in-place if unavailable." }, - "debounce_delay": { "label": "Debounce Delay (ms)", "help": "Milliseconds to wait before dispatching rapid messages. Set 0 to disable." }, - "thread_ttl": { "label": "Thread Participation TTL (hours)", "help": "Hours before bot stops auto-replying in threads it participated in. 0 = always require @mention." }, - "skills": { "label": "Skills Filter", "help": "Limit available skills for this group" }, - "tools": { "label": "Tool Allowlist", "help": "Restrict which tools the agent can use in this group" }, + "text_chunk_limit": { + "label": "Text Chunk Limit", + "help": "Max characters per message" + }, + "group_allow_from": { + "label": "Group Allowed Users", + "help": "Separate allowlist for group senders" + }, + "native_stream": { + "label": "Native Streaming (Agents & AI Apps)", + "help": "Use Slack's ChatStreamer API for native streaming. Falls back to edit-in-place if unavailable." + }, + "debounce_delay": { + "label": "Debounce Delay (ms)", + "help": "Milliseconds to wait before dispatching rapid messages. Set 0 to disable." + }, + "thread_ttl": { + "label": "Thread Participation TTL (hours)", + "help": "Hours before bot stops auto-replying in threads it participated in. 0 = always require @mention." + }, + "skills": { + "label": "Skills Filter", + "help": "Limit available skills for this group" + }, + "tools": { + "label": "Tool Allowlist", + "help": "Restrict which tools the agent can use in this group" + }, "system_prompt": { "label": "System Prompt" } }, "fieldOptions": { @@ -269,10 +377,22 @@ "addTopicPlaceholder": "Topic thread ID", "addTopic": "Add Topic", "fieldConfig": { - "dm_stream": { "label": "DM Streaming", "help": "Stream response progressively in DMs" }, - "group_stream": { "label": "Group Streaming", "help": "Stream response progressively in groups" }, - "draft_transport": { "label": "Draft Preview", "help": "Use stealth draft preview for answer stream in DMs — no notification per edit (requires DM Streaming)" }, - "reasoning_stream": { "label": "Show Reasoning", "help": "Display AI thinking as a separate message before the answer (requires streaming)" } + "dm_stream": { + "label": "DM Streaming", + "help": "Stream response progressively in DMs" + }, + "group_stream": { + "label": "Group Streaming", + "help": "Stream response progressively in groups" + }, + "draft_transport": { + "label": "Draft Preview", + "help": "Use stealth draft preview for answer stream in DMs — no notification per edit (requires DM Streaming)" + }, + "reasoning_stream": { + "label": "Show Reasoning", + "help": "Display AI thinking as a separate message before the answer (requires streaming)" + } }, "fields": { "groupPolicy": "Group Policy", @@ -298,6 +418,16 @@ "formBanner": "After creating, you'll authenticate via QR code and configure allowed users." } }, + "fallback": { + "authRequiredSummary": "Authentication required", + "authRequiredDetail": "Channel instance is enabled but requires sign-in before it can connect.", + "authRequiredHeadline": "Reconnect the channel session", + "authRequiredHint": "Authenticate this channel again to restore the current session.", + "missingCredentialsSummary": "Missing credentials", + "missingCredentialsDetail": "Channel instance is enabled but required credentials are incomplete.", + "missingCredentialsHeadline": "Complete required credentials", + "missingCredentialsHint": "Open credentials and fill the missing or invalid values for this channel." + }, "scopes": { "title": "Required API Permissions", "description": "Your Lark/Feishu app must have these scopes enabled in the Developer Console. Publish a new app version after adding permissions.", diff --git a/ui/web/src/i18n/locales/en/overview.json b/ui/web/src/i18n/locales/en/overview.json index cec040ca..e1ff4c99 100644 --- a/ui/web/src/i18n/locales/en/overview.json +++ b/ui/web/src/i18n/locales/en/overview.json @@ -11,6 +11,7 @@ "costToday": "Cost Today", "agents": "Agents", "channels": "Channels", + "channelsAttention": "{{count}} need attention", "running": "running", "online": "online", "users": "{{count}} users", @@ -33,6 +34,12 @@ "sessions": "Sessions", "clients": "Clients", "channels": "Channels", + "needsAttention": "Needs attention", + "channelsNeedingAttention": "{{count}} channels", + "failedCount": "{{count}} failed", + "warningCountOne": "{{count}} warning", + "warningCountOther": "{{count}} warnings", + "moreAttention": "+{{count}} more", "active": "{{count}} active", "none": "none", "runtimes": "Runtimes" diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index 950be918..32517a95 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -20,13 +20,33 @@ }, "status": { "running": "Đang chạy", - "stopped": "Đã dừng" + "stopped": "Đã dừng", + "checking": "Đang kiểm tra", + "degraded": "Suy giảm", + "starting": "Đang khởi động", + "registered": "Đã cấu hình", + "failed": "Thất bại" }, "enabled": "Đã bật", "disabled": "Đã tắt", "actions": { "reauthenticate": "Xác thực lại", - "authenticate": "Xác thực để khởi động channel" + "authenticate": "Xác thực để khởi động channel", + "reauthShort": "Xác thực lại", + "openCredentials": "Mở thông tin xác thực", + "openAdvanced": "Mở nâng cao", + "inspect": "Xem sự cố" + }, + "list": { + "failureStreak": "{{count}} lỗi liên tiếp", + "openChannelDetail": "Mở chi tiết channel để xem chẩn đoán mới nhất", + "nextStep": "Bước tiếp theo" + }, + "failureKind": { + "auth": "Xác thực", + "config": "Cấu hình", + "network": "Mạng", + "unknown": "Cần chú ý" }, "delete": { "title": "Xóa phiên bản channel", @@ -74,6 +94,7 @@ }, "detail": { "agent": "Agent: {{name}}", + "checkedRelative": "Đã kiểm tra {{value}}", "advanced": "Nâng cao", "advancedTitle": "Cài đặt nâng cao", "policies": "Chính sách", @@ -88,6 +109,10 @@ "behaviorDesc": "Cấu hình hành vi phản hồi", "accessControl": "Kiểm soát truy cập", "accessControlDesc": "Giới hạn truy cập người dùng", + "reviewDiagnostics": "Xem chẩn đoán mới nhất của channel này trước khi thay đổi cài đặt.", + "whatHappened": "Điều gì đã xảy ra", + "recommendedAction": "Hành động được khuyến nghị", + "technicalDetail": "Chi tiết kỹ thuật", "tabs": { "general": "Chung", "config": "Cấu hình", @@ -131,6 +156,16 @@ "saveGroups": "Lưu nhóm", "saving": "Đang lưu..." }, + "timeline": { + "title": "Dòng thời gian", + "firstFailed": "Lỗi đầu tiên", + "lastChecked": "Kiểm tra gần nhất", + "failures": "Lỗi", + "failureStreak": "{{count}} lần liên tiếp", + "failureTotal": "Tổng cộng {{count}}", + "lastHealthy": "Hoạt động tốt gần nhất", + "noData": "Chưa có lần kiểm tra channel nào gần đây." + }, "managers": { "description": "Quản lý người dùng có quyền quản lý trong cuộc trò chuyện nhóm.", "groups": "Nhóm", @@ -298,6 +333,16 @@ "formBanner": "Sau khi tạo, bạn sẽ xác thực bằng mã QR và cấu hình người dùng được phép." } }, + "fallback": { + "authRequiredSummary": "Cần xác thực", + "authRequiredDetail": "Channel đã được bật nhưng cần đăng nhập trước khi có thể kết nối.", + "authRequiredHeadline": "Kết nối lại phiên channel", + "authRequiredHint": "Xác thực lại channel này để khôi phục phiên hiện tại.", + "missingCredentialsSummary": "Thiếu thông tin xác thực", + "missingCredentialsDetail": "Channel đã được bật nhưng thiếu thông tin xác thực bắt buộc.", + "missingCredentialsHeadline": "Điền thông tin xác thực bắt buộc", + "missingCredentialsHint": "Mở phần thông tin xác thực và điền các giá trị còn thiếu hoặc không hợp lệ cho channel này." + }, "scopes": { "title": "Quyền API cần thiết", "description": "Ứng dụng Lark/Feishu cần bật các scope này trong Developer Console. Publish phiên bản mới sau khi thêm quyền.", diff --git a/ui/web/src/i18n/locales/vi/overview.json b/ui/web/src/i18n/locales/vi/overview.json index 78b87a11..0cb65d98 100644 --- a/ui/web/src/i18n/locales/vi/overview.json +++ b/ui/web/src/i18n/locales/vi/overview.json @@ -55,6 +55,7 @@ "statCards": { "agents": "Agent", "channels": "Channel", + "channelsAttention": "{{count}} cần chú ý", "inOut": "Vào / Ra", "online": "Trực tuyến", "requestsToday": "Request hôm nay", @@ -66,6 +67,12 @@ "systemHealth": { "active": "hoạt động", "channels": "Channel", + "needsAttention": "Cần chú ý", + "channelsNeedingAttention": "{{count}} channel", + "failedCount": "{{count}} thất bại", + "warningCountOne": "{{count}} cảnh báo", + "warningCountOther": "{{count}} cảnh báo", + "moreAttention": "+{{count}} nữa", "clients": "Máy khách", "database": "Cơ sở dữ liệu", "none": "không có", diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 2fbf7245..7dd7a31c 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -20,13 +20,33 @@ }, "status": { "running": "运行中", - "stopped": "已停止" + "stopped": "已停止", + "checking": "检查中", + "degraded": "降级", + "starting": "启动中", + "registered": "已配置", + "failed": "失败" }, "enabled": "已启用", "disabled": "已禁用", "actions": { "reauthenticate": "重新认证", - "authenticate": "认证以启动Channel" + "authenticate": "认证以启动Channel", + "reauthShort": "重新认证", + "openCredentials": "打开凭据", + "openAdvanced": "打开高级设置", + "inspect": "查看问题" + }, + "list": { + "failureStreak": "连续 {{count}} 次失败", + "openChannelDetail": "打开 Channel 详情查看最新诊断", + "nextStep": "下一步" + }, + "failureKind": { + "auth": "认证", + "config": "配置", + "network": "网络", + "unknown": "需关注" }, "delete": { "title": "删除Channel实例", @@ -74,6 +94,7 @@ }, "detail": { "agent": "Agent: {{name}}", + "checkedRelative": "最近检查:{{value}}", "advanced": "高级设置", "advancedTitle": "高级配置", "policies": "策略", @@ -88,6 +109,10 @@ "behaviorDesc": "响应行为配置", "accessControl": "访问控制", "accessControlDesc": "用户访问限制", + "reviewDiagnostics": "在更改设置前,请先查看此 Channel 的最新诊断。", + "whatHappened": "发生了什么", + "recommendedAction": "建议操作", + "technicalDetail": "技术细节", "tabs": { "general": "基本信息", "config": "配置", @@ -131,6 +156,16 @@ "saveGroups": "保存群组", "saving": "保存中..." }, + "timeline": { + "title": "时间线", + "firstFailed": "首次失败", + "lastChecked": "最近检查", + "failures": "失败次数", + "failureStreak": "连续 {{count}} 次", + "failureTotal": "共 {{count}} 次", + "lastHealthy": "最近健康", + "noData": "尚无最近的 Channel 检查记录。" + }, "managers": { "description": "管理群组聊天中具有管理员权限的用户。", "groups": "群组", @@ -298,6 +333,16 @@ "formBanner": "创建后,您将通过二维码进行认证并配置允许的用户。" } }, + "fallback": { + "authRequiredSummary": "需要认证", + "authRequiredDetail": "该 Channel 已启用,但需要先登录才能连接。", + "authRequiredHeadline": "重新连接 Channel 会话", + "authRequiredHint": "请重新认证此 Channel,以恢复当前会话。", + "missingCredentialsSummary": "缺少凭据", + "missingCredentialsDetail": "该 Channel 已启用,但必填凭据不完整。", + "missingCredentialsHeadline": "补全必填凭据", + "missingCredentialsHint": "打开凭据设置,补全缺失或无效的字段。" + }, "scopes": { "title": "所需API权限", "description": "您的 Lark/飞书 应用需要在开发者控制台启用这些权限。添加权限后请发布新版本。", diff --git a/ui/web/src/i18n/locales/zh/overview.json b/ui/web/src/i18n/locales/zh/overview.json index 0b9e0487..8a47d3d6 100644 --- a/ui/web/src/i18n/locales/zh/overview.json +++ b/ui/web/src/i18n/locales/zh/overview.json @@ -55,6 +55,7 @@ "statCards": { "agents": "Agent", "channels": "Channel", + "channelsAttention": "{{count}} 个需关注", "inOut": "输入 / 输出", "online": "在线", "requestsToday": "今日请求", @@ -66,6 +67,12 @@ "systemHealth": { "active": "活跃", "channels": "Channel", + "needsAttention": "需要关注", + "channelsNeedingAttention": "{{count}} 个 Channel", + "failedCount": "{{count}} 个失败", + "warningCountOne": "{{count}} 个警告", + "warningCountOther": "{{count}} 个警告", + "moreAttention": "另有 +{{count}} 个", "clients": "客户端", "database": "数据库", "none": "无", diff --git a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx index d26c3ce0..9c2c019b 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-detail-page.tsx @@ -1,6 +1,10 @@ -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { useSearchParams } from "react-router"; +import { AlertTriangle, 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"; @@ -12,6 +16,14 @@ import { ChannelAdvancedDialog } from "./channel-advanced-dialog"; import { ConfirmDeleteDialog } from "@/components/shared/confirm-delete-dialog"; import { DetailPageSkeleton } from "@/components/shared/loading-skeleton"; import { useChannels } from "../hooks/use-channels"; +import { channelsWithAuth, reauthDialogs } from "../channel-wizard-registry"; +import { + formatRelativeTime, + getChannelCheckedLabel, + getChannelRemediationMeta, + getRenderableChannelStatus, + getChannelStatusMeta, +} from "../channels-status-view"; interface ChannelDetailPageProps { instanceId: string; @@ -19,8 +31,29 @@ interface ChannelDetailPageProps { onDelete?: (instance: { id: string; name: string }) => void; } -export function ChannelDetailPage({ instanceId, onBack, onDelete }: ChannelDetailPageProps) { +const DEFAULT_CHANNEL_DETAIL_TAB = "general"; +const baseChannelDetailTabs = new Set(["general", "credentials", "managers"]); + +export function resolveChannelDetailTab( + requestedTab: string | null, + isTelegram: boolean, +) { + if (!requestedTab) return DEFAULT_CHANNEL_DETAIL_TAB; + if (requestedTab === "groups") { + return isTelegram ? "groups" : DEFAULT_CHANNEL_DETAIL_TAB; + } + return baseChannelDetailTabs.has(requestedTab) + ? requestedTab + : DEFAULT_CHANNEL_DETAIL_TAB; +} + +export function ChannelDetailPage({ + instanceId, + onBack, + onDelete, +}: ChannelDetailPageProps) { const { t } = useTranslation("channels"); + const [searchParams] = useSearchParams(); const { instance, loading, @@ -35,18 +68,38 @@ export function ChannelDetailPage({ instanceId, onBack, onDelete }: ChannelDetai const [activeTab, setActiveTab] = useState("general"); const [advancedOpen, setAdvancedOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); + const [reauthOpen, setReauthOpen] = useState(false); - if (loading || !instance) { - return ; - } - - const status = channels[instance.name] ?? null; + const status = instance + ? getRenderableChannelStatus(channels[instance.name] ?? null, instance) + : null; const agentName = (() => { + if (!instance) return ""; const agent = agents.find((a) => a.id === instance.agent_id); - return agent?.display_name || agent?.agent_key || instance.agent_id.slice(0, 8); + return ( + agent?.display_name || agent?.agent_key || instance.agent_id.slice(0, 8) + ); })(); - const isTelegram = instance.channel_type === "telegram"; + const isTelegram = instance?.channel_type === "telegram"; + const supportsReauth = instance + ? channelsWithAuth.has(instance.channel_type) + : false; + const statusMeta = getChannelStatusMeta(status, instance?.enabled ?? false, t); + const remediation = getChannelRemediationMeta(status, supportsReauth, t); + const checkedLabel = getChannelCheckedLabel(status, t); + + useEffect(() => { + if (!instance) return; + setActiveTab(resolveChannelDetailTab(searchParams.get("tab"), isTelegram)); + }, [instance, isTelegram, searchParams]); + + useEffect(() => { + if (!instance) return; + if (searchParams.get("advanced") === "1") { + setAdvancedOpen(true); + } + }, [instance, searchParams]); const handleDelete = () => { if (onDelete) { @@ -54,6 +107,107 @@ export function ChannelDetailPage({ instanceId, onBack, onDelete }: ChannelDetai } }; + const handleRemediationAction = () => { + switch (remediation?.target) { + case "credentials": + setActiveTab("credentials"); + break; + case "advanced": + setAdvancedOpen(true); + break; + case "reauth": + if (supportsReauth) { + setReauthOpen(true); + } + break; + default: + break; + } + }; + + const headerAction = + remediation && remediation.target !== "details" + ? { label: remediation.label, onClick: handleRemediationAction } + : null; + + const timelineItems = useMemo(() => { + const items: Array<{ label: string; value: string }> = []; + const firstFailed = formatRelativeTime(status?.first_failed_at); + const lastChecked = formatRelativeTime(status?.checked_at); + const lastHealthy = formatRelativeTime(status?.last_healthy_at); + + if (firstFailed) { + items.push({ + label: t("detail.timeline.firstFailed", { defaultValue: "First failed" }), + value: firstFailed, + }); + } + if (lastChecked) { + items.push({ + label: t("detail.timeline.lastChecked", { defaultValue: "Last checked" }), + value: lastChecked, + }); + } + if (status?.consecutive_failures) { + items.push({ + label: t("detail.timeline.failures", { defaultValue: "Failures" }), + value: t("detail.timeline.failureStreak", { + defaultValue: "{{count}} in a row", + count: status.consecutive_failures, + }), + }); + } else if (status?.failure_count) { + items.push({ + label: t("detail.timeline.failures", { defaultValue: "Failures" }), + value: t("detail.timeline.failureTotal", { + defaultValue: "{{count}} total", + count: status.failure_count, + }), + }); + } + if (lastHealthy) { + items.push({ + label: t("detail.timeline.lastHealthy", { defaultValue: "Last healthy" }), + value: lastHealthy, + }); + } + + return items; + }, [ + status?.checked_at, + status?.consecutive_failures, + status?.failure_count, + status?.first_failed_at, + status?.last_healthy_at, + t, + ]); + + const showDiagnosticsCard = + status?.state === "failed" || + status?.state === "degraded" || + !!status?.remediation || + !!status?.consecutive_failures || + !!status?.first_failed_at; + + const neutralHealthNote = + !showDiagnosticsCard && + (status?.state === "healthy" || status?.state === "starting") && + checkedLabel; + + const diagnosticsHint = + remediation?.hint || + t("detail.reviewDiagnostics", { + defaultValue: "Review the latest diagnosis in this channel before changing settings.", + }); + + const ReauthDialog = supportsReauth + ? reauthDialogs[instance?.channel_type ?? ""] + : null; + + if (loading || !instance) { + return ; + } + return (
setAdvancedOpen(true)} onDelete={handleDelete} + primaryAction={headerAction} />
-
+
+ {showDiagnosticsCard && status && ( +
+
+
+
+ + + {t("detail.whatHappened", { + defaultValue: "What happened", + })} + +
+

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

+

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

+ +
+

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

+

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

+

+ {diagnosticsHint} +

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

+ {status.detail} +

+
+ )} +
+ +
+

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

+
+ {timelineItems.length > 0 ? ( + timelineItems.map((item) => ( +
+ + {item.label} + + + {item.value} + +
+ )) + ) : ( +
+ {checkedLabel || + t("detail.timeline.noData", { + defaultValue: "No recent channel checks recorded yet.", + })} +
+ )} +
+
+
+
+ )} + + {neutralHealthNote && ( +
+ + {neutralHealthNote} +
+ )} + - {t("detail.tabs.general")} - {t("detail.tabs.credentials")} - {isTelegram && {t("detail.tabs.groups")}} - {t("detail.tabs.managers")} + + {t("detail.tabs.general")} + + + {t("detail.tabs.credentials")} + + {isTelegram && ( + + {t("detail.tabs.groups")} + + )} + + {t("detail.tabs.managers")} + @@ -119,16 +386,33 @@ export function ChannelDetailPage({ instanceId, onBack, onDelete }: ChannelDetai onUpdate={updateInstance} /> + {ReauthDialog && ( + { + setReauthOpen(false); + }} + /> + )} + {onDelete && ( { - onDelete({ id: instance.id, name: instance.display_name || instance.name }); + onDelete({ + id: instance.id, + name: instance.display_name || instance.name, + }); setDeleteOpen(false); }} /> diff --git a/ui/web/src/pages/channels/channel-detail/channel-header.tsx b/ui/web/src/pages/channels/channel-detail/channel-header.tsx index 3be129d8..1c5728b0 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-header.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-header.tsx @@ -2,90 +2,142 @@ import { useTranslation } from "react-i18next"; import { ArrowLeft, Radio, Settings, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { cn } from "@/lib/utils"; -import { channelTypeLabels } from "../channels-status-view"; -import type { ChannelInstanceData } from "@/types/channel"; +import { channelTypeLabels, getChannelCheckedLabel, getChannelFailureKindLabel, getChannelStatusMeta } from "../channels-status-view"; +import type { + ChannelInstanceData, + ChannelRuntimeStatus, +} from "@/types/channel"; interface ChannelHeaderProps { instance: ChannelInstanceData; - status: { running: boolean } | null; + status: ChannelRuntimeStatus | null; agentName: string; onBack: () => void; onAdvanced: () => void; onDelete: () => void; + primaryAction?: { + label: string; + onClick: () => void; + } | null; } -export function ChannelHeader({ instance, status, agentName, onBack, onAdvanced, onDelete }: ChannelHeaderProps) { +export function ChannelHeader({ + instance, + status, + agentName, + onBack, + onAdvanced, + onDelete, + primaryAction, +}: ChannelHeaderProps) { const { t } = useTranslation("channels"); const displayTitle = instance.display_name || instance.name; - const typeLabel = channelTypeLabels[instance.channel_type] || instance.channel_type; + const typeLabel = + channelTypeLabels[instance.channel_type] || instance.channel_type; + const statusMeta = getChannelStatusMeta(status, instance.enabled, t); + const failureKind = getChannelFailureKindLabel(status?.failure_kind, t); + const checkedLabel = getChannelCheckedLabel(status, t); + const summaryLine = status?.summary || statusMeta.label; return ( -
- +
+
+ - {/* Channel icon */} -
- -
+
+ +
- {/* Channel info */} -
-
-

{displayTitle}

- - - - - - {instance.enabled ? t("enabled") : t("disabled")} - - - {status && ( - - {status.running ? t("status.running") : t("status.stopped")} +
+
+

{displayTitle}

+ + + + + + {instance.enabled ? t("enabled") : t("disabled")} + + + + {statusMeta.label} - )} + {failureKind && {failureKind}} +
+
+ {instance.name} + · + + {typeLabel} + + · + {t("detail.agent", { name: agentName })} + {checkedLabel && ( + <> + · + {checkedLabel} + + )} +
+
+ {summaryLine} +
-
- {instance.name} - · - {typeLabel} - · - {t("detail.agent", { name: agentName })} -
-
- + {primaryAction && ( + + )} - {!instance.is_default && ( - )} + + {!instance.is_default && ( + + )} +
); diff --git a/ui/web/src/pages/channels/channel-list-row.tsx b/ui/web/src/pages/channels/channel-list-row.tsx index 40706e7c..6c763edc 100644 --- a/ui/web/src/pages/channels/channel-list-row.tsx +++ b/ui/web/src/pages/channels/channel-list-row.tsx @@ -1,93 +1,156 @@ -import { Radio, QrCode, Trash2 } from "lucide-react"; +import { QrCode, Radio, Trash2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import type { ChannelInstanceData } from "@/types/channel"; -import { channelTypeLabels } from "./channels-status-view"; +import { cn } from "@/lib/utils"; +import type { + ChannelInstanceData, + ChannelRuntimeStatus, +} from "@/types/channel"; +import { + channelTypeLabels, + getChannelCheckedLabel, + getChannelFailureKindLabel, + getChannelRemediationMeta, + getChannelStatusMeta, +} from "./channels-status-view"; import { channelsWithAuth } from "./channel-wizard-registry"; interface ChannelListRowProps { instance: ChannelInstanceData; - status: { running: boolean } | null; + status: ChannelRuntimeStatus | null; agentName: string; onClick: () => void; onAuth?: () => void; onDelete?: () => void; } -export function ChannelListRow({ instance, status, agentName, onClick, onAuth, onDelete }: ChannelListRowProps) { +export function ChannelListRow({ + instance, + status, + agentName, + onClick, + onAuth, + onDelete, +}: ChannelListRowProps) { const { t } = useTranslation("channels"); const displayName = instance.display_name || instance.name; - const isRunning = status?.running ?? false; + const supportsReauth = channelsWithAuth.has(instance.channel_type); + const statusMeta = getChannelStatusMeta(status, instance.enabled, t); + const failureKind = getChannelFailureKindLabel(status?.failure_kind, t); + const checkedLabel = getChannelCheckedLabel(status, t); + const remediation = getChannelRemediationMeta(status, supportsReauth, t); + const summaryLine = status?.summary || statusMeta.label; + const streakLabel = + status?.consecutive_failures && status.consecutive_failures > 1 + ? t("list.failureStreak", { + defaultValue: "{{count}} failures in a row", + count: status.consecutive_failures, + }) + : checkedLabel; + const nextStepLabel = + remediation?.label || t("actions.inspect", { defaultValue: "Inspect issue" }); + const nextStepHint = + remediation?.headline || + t("list.openChannelDetail", { + defaultValue: "Open channel detail for the latest diagnosis", + }); return ( - + +
+ {onAuth && supportsReauth && ( + + )} + {onDelete && !instance.is_default && ( + + )}
- {instance.display_name && ( -
{instance.name}
- )}
- - {/* Type */} -
- - {channelTypeLabels[instance.channel_type] || instance.channel_type} - -
- - {/* Status text */} -
- {!instance.enabled - ? t("disabled") - : isRunning ? t("status.running") : t("status.stopped")} -
- - {/* Agent */} -
- {agentName} -
- - {/* Actions */} -
- {onAuth && channelsWithAuth.has(instance.channel_type) && ( - - )} - {onDelete && !instance.is_default && ( - - )} -
- +
); } diff --git a/ui/web/src/pages/channels/channels-page.tsx b/ui/web/src/pages/channels/channels-page.tsx index 1ff397e2..248a80ae 100644 --- a/ui/web/src/pages/channels/channels-page.tsx +++ b/ui/web/src/pages/channels/channels-page.tsx @@ -19,6 +19,10 @@ import { useAgents } from "@/pages/agents/hooks/use-agents"; import { useMinLoading } from "@/hooks/use-min-loading"; import { useDeferredLoading } from "@/hooks/use-deferred-loading"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; +import { + getChannelAttentionPriority, + getRenderableChannelStatus, +} from "./channels-status-view"; export function ChannelsPage() { const { t } = useTranslation("channels"); @@ -112,9 +116,16 @@ export function ChannelsPage() { return agent?.display_name || agent?.agent_key || agentId.slice(0, 8); }; - const getStatus = (instanceName: string) => { - return channels[instanceName] ?? null; - }; + const getStatus = (instance: ChannelInstanceData) => + getRenderableChannelStatus(channels[instance.name] ?? null, instance); + + const sortedInstances = [...instances].sort((a, b) => { + const priorityDiff = + getChannelAttentionPriority(getStatus(b), b.enabled) - + getChannelAttentionPriority(getStatus(a), a.enabled); + if (priorityDiff !== 0) return priorityDiff; + return (a.display_name || a.name).localeCompare(b.display_name || b.name); + }); return (
@@ -154,11 +165,11 @@ export function ChannelsPage() { ) : ( <>
- {instances.map((inst) => ( + {sortedInstances.map((inst) => ( navigate(`/channels/${inst.id}`)} onAuth={channelsWithAuth.has(inst.channel_type) ? () => setQrTarget(inst) : undefined} diff --git a/ui/web/src/pages/channels/channels-status-view.tsx b/ui/web/src/pages/channels/channels-status-view.tsx index 7a0339ae..d5404c28 100644 --- a/ui/web/src/pages/channels/channels-status-view.tsx +++ b/ui/web/src/pages/channels/channels-status-view.tsx @@ -1,4 +1,6 @@ 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"; @@ -6,7 +8,12 @@ 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 { ChannelStatus } from "./hooks/use-channels"; +import type { + ChannelInstanceData, + ChannelRuntimeStatus, +} from "@/types/channel"; + +export type ChannelStatus = ChannelRuntimeStatus; const channelTypeLabels: Record = { telegram: "Telegram", @@ -18,8 +25,332 @@ const channelTypeLabels: Record = { 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, + }; +} + interface ChannelsStatusViewProps { channels: Record; loading: boolean; @@ -27,7 +358,12 @@ interface ChannelsStatusViewProps { refresh: () => void; } -export function ChannelsStatusView({ channels, loading, spinning, refresh }: ChannelsStatusViewProps) { +export function ChannelsStatusView({ + channels, + loading, + spinning, + refresh, +}: ChannelsStatusViewProps) { const { t } = useTranslation("channels"); const entries = Object.entries(channels); const showSkeleton = useDeferredLoading(loading && entries.length === 0); @@ -38,8 +374,17 @@ export function ChannelsStatusView({ channels, loading, spinning, refresh }: Cha title={t("title")} description={t("statusDescription")} actions={ - } /> @@ -59,28 +404,35 @@ export function ChannelsStatusView({ channels, loading, spinning, refresh }: Cha /> ) : (
- {entries.map(([name, status]: [string, ChannelStatus]) => ( -
-
-

- {channelTypeLabels[name] || name} -

- {status.enabled ? ( - {t("enabled")} - ) : ( - {t("disabled")} + {entries.map(([name, status]) => { + const meta = getChannelStatusMeta(status, status.enabled, t); + const checked = getChannelCheckedLabel(status, t); + const failureKind = getChannelFailureKindLabel( + status.failure_kind, + t, + ); + + return ( +
+
+

+ {channelTypeLabels[name] || name} +

+ {meta.label} +
+ {status.summary && ( +

{status.summary}

)} +
+ {failureKind && {failureKind}} + {checked && {checked}} +
-
- - - {status.running ? t("status.running") : t("status.stopped")} - -
-
- ))} + ); + })}
)}
diff --git a/ui/web/src/pages/channels/hooks/use-channels.ts b/ui/web/src/pages/channels/hooks/use-channels.ts index 4336b05c..ad138bdf 100644 --- a/ui/web/src/pages/channels/hooks/use-channels.ts +++ b/ui/web/src/pages/channels/hooks/use-channels.ts @@ -2,11 +2,9 @@ import { useState, useEffect, useCallback } from "react"; import { useWs } from "@/hooks/use-ws"; import { useAuthStore } from "@/stores/use-auth-store"; import { Methods } from "@/api/protocol"; +import type { ChannelRuntimeStatus } from "@/types/channel"; -export interface ChannelStatus { - enabled: boolean; - running: boolean; -} +export type ChannelStatus = ChannelRuntimeStatus; export function useChannels() { const ws = useWs(); diff --git a/ui/web/src/pages/overview/overview-page.tsx b/ui/web/src/pages/overview/overview-page.tsx index 58cbced7..dfe2afc9 100644 --- a/ui/web/src/pages/overview/overview-page.tsx +++ b/ui/web/src/pages/overview/overview-page.tsx @@ -31,12 +31,18 @@ import { CronJobsCard } from "./cron-jobs-card"; import { RecentRequestsCard } from "./recent-requests-card"; import { QuotaUsageCard } from "./quota-usage-card"; import { useRuntimes } from "@/pages/skills/hooks/use-runtimes"; +import { + getChannelAttentionPriority, + getChannelStatusFallback, +} from "@/pages/channels/channels-status-view"; +import { useChannelInstances } from "@/pages/channels/hooks/use-channel-instances"; const UsagePage = lazy(() => import("@/pages/usage/usage-page").then((m) => ({ default: m.UsagePage })), ); const REFRESH_INTERVAL = 30_000; +const MAX_OVERVIEW_CHANNEL_INSTANCES = 200; export function OverviewPage() { const { t } = useTranslation("overview"); @@ -55,6 +61,10 @@ export function OverviewPage() { const { providers, loading: providersLoading } = useProviders(); const { runtimes } = useRuntimes(); const { traces } = useTraces({ limit: 8 }); + const { instances: channelInstances, total: channelInstanceTotal } = useChannelInstances({ + limit: MAX_OVERVIEW_CHANNEL_INSTANCES, + offset: 0, + }); const hasNoProviders = !providersLoading && providers.length === 0; const hasNoEnabledProviders = @@ -89,10 +99,32 @@ export function OverviewPage() { const agents = status?.agents ?? []; const runningAgents = agents.filter((a) => a.isRunning).length; const agentTotal = status?.agentTotal ?? agents.length; - const channelEntries = channelStatusData?.channels - ? Object.entries(channelStatusData.channels) - : []; + const channelStatusMap = channelStatusData?.channels ?? {}; + const canSynthesizeOverviewFallbacks = + channelInstanceTotal > 0 && + channelInstanceTotal <= MAX_OVERVIEW_CHANNEL_INSTANCES && + channelInstances.length >= channelInstanceTotal; + const channelEntries = (() => { + const combined = new Map(Object.entries(channelStatusMap)); + if (canSynthesizeOverviewFallbacks) { + for (const instance of channelInstances) { + if (combined.has(instance.name)) continue; + const fallback = getChannelStatusFallback(instance); + if (fallback) { + combined.set(instance.name, fallback); + } + } + } + return [...combined.entries()]; + })(); + const totalChannelCount = Math.max(channelEntries.length, channelInstanceTotal); const channelsOnline = channelEntries.filter(([, c]) => c.running).length; + const channelsNeedingAttention = channelEntries.filter( + ([, c]) => getChannelAttentionPriority(c, c.enabled) > 0, + ).length; + const overviewAttentionCount = canSynthesizeOverviewFallbacks + ? channelsNeedingAttention + : null; const enabledProviders = providers.filter((p) => p.enabled); const clientList = health?.clients ?? []; @@ -206,11 +238,20 @@ export function OverviewPage() { icon={Radio} label={t("statCards.channels")} value={ - channelEntries.length > 0 - ? `${channelsOnline} / ${channelEntries.length}` + totalChannelCount > 0 + ? `${channelsOnline} / ${totalChannelCount}` : "0" } - sub={channelEntries.length > 0 ? t("statCards.online") : undefined} + sub={ + totalChannelCount > 0 + ? overviewAttentionCount && overviewAttentionCount > 0 + ? t("statCards.channelsAttention", { + defaultValue: "{{count}} need attention", + count: overviewAttentionCount, + }) + : t("statCards.online") + : undefined + } />
diff --git a/ui/web/src/pages/overview/system-health-card.tsx b/ui/web/src/pages/overview/system-health-card.tsx index e87b097f..19cdb839 100644 --- a/ui/web/src/pages/overview/system-health-card.tsx +++ b/ui/web/src/pages/overview/system-health-card.tsx @@ -16,6 +16,7 @@ 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"; function StatusDot({ ok }: { ok: boolean | undefined }) { if (ok === undefined) @@ -72,6 +73,22 @@ export function SystemHealthCard({ runtimeEntries?: RuntimeInfo[]; }) { const { t } = useTranslation("overview"); + const degradedCount = channelEntries.filter( + ([, ch]) => ch.state === "degraded", + ).length; + const failedCount = channelEntries.filter( + ([, ch]) => ch.state === "failed", + ).length; + const attentionEntries = [...channelEntries] + .filter(([, ch]) => getChannelAttentionPriority(ch, ch.enabled) > 0) + .sort( + (a, b) => + 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 ( @@ -82,7 +99,9 @@ export function SystemHealthCard({ {health?.version && (
- {cleanVersion(health.version)} + + {cleanVersion(health.version)} + {health.updateAvailable === false && ( )} @@ -167,22 +186,102 @@ export function SystemHealthCard({ {channelEntries.length > 0 && (
-

- {t("systemHealth.channels")} -

-
- {channelEntries.map(([name, ch]) => ( - - - {name} +
+

+ {t("systemHealth.channels")} +

+ {(degradedCount > 0 || failedCount > 0) && ( + + {failedCount > 0 + ? t("systemHealth.failedCount", { + defaultValue: "{{count}} failed", + count: failedCount, + }) + : degradedCount === 1 + ? t("systemHealth.warningCountOne", { + defaultValue: "{{count}} warning", + count: degradedCount, + }) + : t("systemHealth.warningCountOther", { + defaultValue: "{{count}} warnings", + count: degradedCount, + })} - ))} + )}
+
+ {channelEntries.map(([name, ch]) => { + const meta = getChannelStatusMeta(ch, ch.enabled, t); + return ( + + + {name} + + ); + })} +
+ {attentionPreview.length > 0 && ( +
+
+

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

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

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

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

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

+ )} +
+
+ )}
)} diff --git a/ui/web/src/pages/overview/types.ts b/ui/web/src/pages/overview/types.ts index 51440994..5fb05cb3 100644 --- a/ui/web/src/pages/overview/types.ts +++ b/ui/web/src/pages/overview/types.ts @@ -1,3 +1,5 @@ +import type { ChannelRuntimeStatus } from "@/types/channel"; + export interface ClientInfo { id: string; remoteAddr: string; @@ -34,10 +36,7 @@ export interface StatusPayload { clients?: number; } -export interface ChannelStatusEntry { - enabled: boolean; - running: boolean; -} +export type ChannelStatusEntry = ChannelRuntimeStatus; export interface ChannelStatusPayload { channels: Record; diff --git a/ui/web/src/types/channel.ts b/ui/web/src/types/channel.ts index 5e346ec7..bfedd5b8 100644 --- a/ui/web/src/types/channel.ts +++ b/ui/web/src/types/channel.ts @@ -13,6 +13,34 @@ export interface ChannelInstanceData { updated_at: string; } +export interface ChannelRuntimeStatus { + enabled: boolean; + running: boolean; + state?: + | "registered" + | "starting" + | "healthy" + | "degraded" + | "failed" + | "stopped"; + summary?: string; + detail?: string; + failure_kind?: "auth" | "config" | "network" | "unknown"; + retryable?: boolean; + checked_at?: string; + failure_count?: number; + consecutive_failures?: number; + first_failed_at?: string; + last_failed_at?: string; + last_healthy_at?: string; + remediation?: { + code: "reauth" | "open_credentials" | "open_advanced" | "check_network"; + headline: string; + hint?: string; + target?: "credentials" | "advanced" | "reauth" | "details"; + }; +} + export interface ChannelInstanceInput { name: string; display_name?: string;