feat: add actionable channel health diagnostics and remediation (#634)

* feat: add channel health diagnostics

* fix(channels): harden startup failure reporting

- sanitize operator-facing failure details instead of exposing raw upstream errors

- keep registered channel instances failed during reload/startup errors and clean up pre-registration failure entries

- stop Telegram warning states from reporting running=true after polling or startup failures

* feat(channels-ui): surface actionable health diagnostics

- expand channel and overview views with richer health summaries, remediation hints, and failure history

- refine the channel dashboard layout so diagnostics are easier to scan instead of reading a wall of text

- keep shared web and desktop channel types aligned with the new health payload

* fix(web): harden channel health diagnostics states

- treat enabled channels without backend status as checking instead of stopped\n- align fallback remediation and overview attention synthesis with channel reality\n- localize diagnostics copy and reject invalid channel detail tabs\n\nRefs #628\nRefs #633

* docs(pr-assets): add channel diagnostics visual review pack
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-04-02 09:30:11 +07:00
1 parent abcb9614bc
commit 4cd7b0a8d4
35 files changed
+3632 -420

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

+668
View File
@@ -0,0 +1,668 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PR #634 — Channel Health Diagnostics UI Before / After</title>
<style>
:root {
--bg: #100705;
--panel: #1b110c;
--panel-2: #241610;
--text: #f7eee8;
--muted: #ceb6a8;
--line: rgba(255, 220, 196, 0.16);
--accent: #ff7a3d;
--accent-soft: rgba(255, 122, 61, 0.12);
--good: #4ad18c;
--warn: #ffd166;
--shadow: 0 26px 80px rgba(0, 0, 0, 0.34);
}
[data-theme="light"] {
--bg: #f7efe9;
--panel: #fffaf6;
--panel-2: #fff4ec;
--text: #23140d;
--muted: #71584b;
--line: rgba(122, 73, 44, 0.14);
--accent: #cf5c21;
--accent-soft: rgba(207, 92, 33, 0.1);
--good: #127a4b;
--warn: #9a6a07;
--shadow: 0 20px 60px rgba(72, 38, 14, 0.14);
}
* {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
font-family:
"Inter",
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(255, 122, 61, 0.16), transparent 26%),
radial-gradient(circle at top right, rgba(255, 210, 186, 0.08), transparent 20%),
linear-gradient(180deg, rgba(255, 255, 255, 0.02), rgba(255, 255, 255, 0)),
var(--bg);
}
a {
color: inherit;
}
.theme-toggle {
position: fixed;
top: 18px;
right: 18px;
z-index: 30;
border: 1px solid var(--line);
background: var(--panel);
color: var(--text);
border-radius: 999px;
padding: 10px 14px;
font: inherit;
cursor: pointer;
box-shadow: var(--shadow);
}
.wrap {
width: min(1480px, calc(100vw - 40px));
margin: 0 auto;
padding: 28px 0 56px;
}
.hero,
.section {
border: 1px solid var(--line);
border-radius: 28px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.015));
box-shadow: var(--shadow);
}
.hero {
padding: 30px;
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 7px 11px;
border-radius: 999px;
border: 1px solid rgba(255, 122, 61, 0.25);
background: var(--accent-soft);
color: var(--accent);
font-size: 12px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h1 {
margin: 16px 0 12px;
font-size: clamp(34px, 5vw, 56px);
line-height: 0.98;
letter-spacing: -0.05em;
}
.hero p,
.section-intro,
.note,
.proof-list li,
.caption,
.bullets li {
color: var(--muted);
}
.hero p {
max-width: 980px;
margin: 0;
font-size: 17px;
line-height: 1.7;
}
.meta-grid,
.proof-grid,
.comparison,
.code-grid,
.assert-grid {
display: grid;
gap: 16px;
}
.meta-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
margin-top: 22px;
}
.meta-card,
.proof,
.code-card,
.assert-card {
border: 1px solid var(--line);
border-radius: 20px;
background: var(--panel-2);
}
.meta-card {
padding: 15px 16px;
}
.meta-label {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.meta-value {
margin-top: 8px;
font-size: 15px;
line-height: 1.5;
}
.section {
margin-top: 26px;
padding: 26px;
}
.section h2 {
margin: 0;
font-size: 28px;
letter-spacing: -0.04em;
}
.section-intro {
margin: 10px 0 0;
font-size: 15px;
line-height: 1.65;
}
.proof-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 18px;
}
.proof {
padding: 16px;
}
.proof strong,
.code-card strong,
.assert-card strong {
display: block;
margin-bottom: 8px;
font-size: 15px;
}
.comparison {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 22px;
}
.shot {
overflow: hidden;
border: 1px solid var(--line);
border-radius: 22px;
background: var(--panel);
}
.shot-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 15px 16px;
border-bottom: 1px solid var(--line);
}
.shot-title {
font-size: 18px;
font-weight: 800;
}
.tag {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.before {
color: #ffd5d5;
background: rgba(255, 93, 93, 0.14);
border: 1px solid rgba(255, 93, 93, 0.2);
}
.after {
color: #dcffec;
background: rgba(74, 209, 140, 0.14);
border: 1px solid rgba(74, 209, 140, 0.2);
}
.shot img {
display: block;
width: 100%;
height: auto;
background: #0a0503;
}
.caption {
padding: 15px 16px 18px;
font-size: 14px;
line-height: 1.68;
}
.code-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 18px;
}
.code-card,
.assert-card {
padding: 18px;
}
.bullets,
.proof-list {
margin: 0;
padding-left: 18px;
}
.bullets li + li,
.proof-list li + li {
margin-top: 8px;
}
.assert-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
margin-top: 18px;
}
pre {
margin: 10px 0 0;
padding: 16px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.2);
border: 1px solid var(--line);
color: var(--text);
font-size: 12px;
line-height: 1.55;
overflow: auto;
}
.note {
margin-top: 18px;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.03);
font-size: 14px;
line-height: 1.72;
}
code {
padding: 2px 6px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
font-family: "SFMono-Regular", ui-monospace, monospace;
font-size: 0.94em;
}
@media (max-width: 1100px) {
.meta-grid,
.proof-grid,
.comparison,
.code-grid,
.assert-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<button class="theme-toggle" type="button" id="theme-toggle">Toggle theme</button>
<div class="wrap">
<section class="hero">
<div class="eyebrow">Draft PR #634 • Visual Review</div>
<h1>Channel Health Diagnostics UI Before / After</h1>
<p>
This evidence pack documents the user-facing impact of the channel-health diagnostics
work for issue <code>#628</code> 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.
</p>
<div class="meta-grid">
<div class="meta-card">
<div class="meta-label">Issue Status</div>
<div class="meta-value">Partially solved. The product now explains the failure path, but bad credentials still need operator action.</div>
</div>
<div class="meta-card">
<div class="meta-label">Screens Compared</div>
<div class="meta-value">4 paired captures: overview, channels list, auth detail, missing-credentials detail.</div>
</div>
<div class="meta-card">
<div class="meta-label">Chosen Direction</div>
<div class="meta-value">Shared diagnostics + remediation, not a Telegram-only patch.</div>
</div>
<div class="meta-card">
<div class="meta-label">Review Hardening</div>
<div class="meta-value">Added neutral checking state, safer tab fallback, and no partial-data overcounting.</div>
</div>
</div>
</section>
<section class="section">
<h2>What This PR Proves</h2>
<p class="section-intro">
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.
</p>
<div class="proof-grid">
<div class="proof">
<strong>Overview no longer hides the problem</strong>
<ul class="proof-list">
<li>Before: dashboard-level attention existed, but the missing-credentials path was easy to miss.</li>
<li>After: the overview clearly communicates that channel attention exists and aligns with the richer downstream diagnostics.</li>
</ul>
</div>
<div class="proof">
<strong>Channels list now points to action</strong>
<ul class="proof-list">
<li>Before: rows could degrade into vague summaries or omit the useful next step.</li>
<li>After: the row copy names the failure and exposes the remediation action more directly.</li>
</ul>
</div>
<div class="proof">
<strong>Auth failures are diagnosable</strong>
<ul class="proof-list">
<li>Before: the detail page lacked enough structure for a user to understand what failed.</li>
<li>After: the detail page shows what happened, recommended action, credentials entry point, and technical detail.</li>
</ul>
</div>
<div class="proof">
<strong>Config failures are diagnosable</strong>
<ul class="proof-list">
<li>Before: missing credentials did not surface a complete remediation path.</li>
<li>After: the detail page explains the config problem and exposes the credentials action clearly.</li>
</ul>
</div>
</div>
</section>
<section class="section" id="overview">
<h2>Overview</h2>
<p class="section-intro">
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.
</p>
<div class="comparison">
<article class="shot">
<div class="shot-head">
<div class="shot-title">Overview — Before</div>
<div class="tag before">Before</div>
</div>
<img src="before-overview.png" alt="Overview before channel diagnostics improvements" />
<div class="caption">
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.
</div>
</article>
<article class="shot">
<div class="shot-head">
<div class="shot-title">Overview — After</div>
<div class="tag after">After</div>
</div>
<img src="after-overview.png" alt="Overview after channel diagnostics improvements" />
<div class="caption">
After: the overview aligns with the richer diagnostics flow and no longer relies on the old opaque
failure presentation.
</div>
</article>
</div>
</section>
<section class="section" id="channels-list">
<h2>Channels List</h2>
<p class="section-intro">
This is where the PR most clearly replaces vague channel status with named failures and next-step copy.
</p>
<div class="comparison">
<article class="shot">
<div class="shot-head">
<div class="shot-title">Channels List — Before</div>
<div class="tag before">Before</div>
</div>
<img src="before-channels-list.png" alt="Channels list before channel diagnostics improvements" />
<div class="caption">
Before: summaries and remediation copy were incomplete. The list did not consistently tell the user
what failed or which action to take next.
</div>
</article>
<article class="shot">
<div class="shot-head">
<div class="shot-title">Channels List — After</div>
<div class="tag after">After</div>
</div>
<img src="after-channels-list.png" alt="Channels list after channel diagnostics improvements" />
<div class="caption">
After: the row naming and next-step prompts are explicit. The UI now makes the channel-health state
actionable rather than just visible.
</div>
</article>
</div>
</section>
<section class="section" id="auth-detail">
<h2>Auth Failure Detail</h2>
<p class="section-intro">
This captures the original class of incident from <code>#628</code>: a channel is enabled but blocked by
auth/runtime reality. The page now explains that state instead of flattening it.
</p>
<div class="comparison">
<article class="shot">
<div class="shot-head">
<div class="shot-title">Auth Detail — Before</div>
<div class="tag before">Before</div>
</div>
<img src="before-auth-detail.png" alt="Auth failure detail before channel diagnostics improvements" />
<div class="caption">
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.
</div>
</article>
<article class="shot">
<div class="shot-head">
<div class="shot-title">Auth Detail — After</div>
<div class="tag after">After</div>
</div>
<img src="after-auth-detail.png" alt="Auth failure detail after channel diagnostics improvements" />
<div class="caption">
After: the page names the auth failure, provides recommended action, exposes the credentials entry
point, and shows technical detail useful for debugging.
</div>
</article>
</div>
</section>
<section class="section" id="missing-detail">
<h2>Missing Credentials Detail</h2>
<p class="section-intro">
Missing or incomplete channel configuration now gets the same structured treatment as auth failures.
</p>
<div class="comparison">
<article class="shot">
<div class="shot-head">
<div class="shot-title">Missing Credentials — Before</div>
<div class="tag before">Before</div>
</div>
<img src="before-missing-detail.png" alt="Missing credentials detail before channel diagnostics improvements" />
<div class="caption">
Before: the config failure path was visually thin and did not fully guide the user back to the
required credentials work.
</div>
</article>
<article class="shot">
<div class="shot-head">
<div class="shot-title">Missing Credentials — After</div>
<div class="tag after">After</div>
</div>
<img src="after-missing-detail.png" alt="Missing credentials detail after channel diagnostics improvements" />
<div class="caption">
After: the UI names the config problem, explains the credentials requirement, and surfaces the
action path directly on the page.
</div>
</article>
</div>
</section>
<section class="section">
<h2>Additional Hardening Beyond The Screenshots</h2>
<p class="section-intro">
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.
</p>
<div class="code-grid">
<div class="code-card">
<strong>No more false “Stopped” state for unknown-but-enabled channels</strong>
<ul class="bullets">
<li>Enabled channels without backend status now stay neutral as <code>Checking</code>.</li>
<li>This prevents the UI from manufacturing attention where the runtime has not yet reported anything.</li>
</ul>
</div>
<div class="code-card">
<strong>No more partial-data overcounting in overview</strong>
<ul class="bullets">
<li>Overview attention synthesis only runs when the loaded instance slice is complete.</li>
<li>This avoids claiming more failing channels than the current dataset can justify.</li>
</ul>
</div>
<div class="code-card">
<strong>Fallback remediation is channel-specific</strong>
<ul class="bullets">
<li><code>zalo_personal</code> now points to re-auth instead of generic credentials repair.</li>
<li>Generic missing-credentials fallback stays on the credentials path with localized copy.</li>
</ul>
</div>
<div class="code-card">
<strong>Invalid channel detail tabs fail safely</strong>
<ul class="bullets">
<li>Unsupported <code>?tab=</code> values now fall back to <code>general</code>.</li>
<li>Non-Telegram channels can no longer land on the Telegram-only <code>groups</code> tab.</li>
</ul>
</div>
</div>
</section>
<section class="section">
<h2>Assertion Snapshot</h2>
<p class="section-intro">
These structured checks back up the screenshots by recording the UI markers that were present before and after.
</p>
<div class="assert-grid">
<div class="assert-card">
<strong>Before</strong>
<pre>{
"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
}
}</pre>
</div>
<div class="assert-card">
<strong>After</strong>
<pre>{
"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
}
}</pre>
</div>
</div>
<div class="note">
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 <code>#628</code>
is better described as <strong>diagnosable and actionable</strong>, not automatically healed.
</div>
</section>
</div>
<script>
const key = "pr-634-proof-theme";
const root = document.documentElement;
const button = document.getElementById("theme-toggle");
const preferred = window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
const initial = localStorage.getItem(key) || preferred;
function applyTheme(theme) {
root.dataset.theme = theme;
}
applyTheme(initial);
button.addEventListener("click", () => {
const next = root.dataset.theme === "light" ? "dark" : "light";
localStorage.setItem(key, next);
applyTheme(next);
});
</script>
</body>
</html>
+57 -23
View File
@@ -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)
+468 -9
View File
@@ -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 }
+536
View File
@@ -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)
}
}
+23 -1
View File
@@ -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))
}
}
+135 -7
View File
@@ -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,
}
}
+56 -27
View File
@@ -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{
@@ -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() {
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
)
);
}
function DiscordIcon() {
@@ -23,23 +26,55 @@ function DiscordIcon() {
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189z" />
</svg>
)
);
}
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 (
<div
@@ -66,8 +101,15 @@ export function ChannelCard({ instance, status, agentName, onToggleEnabled, onCl
<span className={`w-1.5 h-1.5 rounded-full ${dotColor}`} />
<span className="text-[11px] text-text-muted">{statusText}</span>
<span className="text-[11px] text-text-muted">·</span>
<span className="text-[11px] text-text-muted truncate">{agentName}</span>
<span className="text-[11px] text-text-muted truncate">
{agentName}
</span>
</div>
{summaryHint && (
<div className="mt-1 truncate text-[11px] text-text-muted">
{summaryHint}
</div>
)}
</div>
{/* Enable toggle */}
@@ -79,5 +121,5 @@ export function ChannelCard({ instance, status, agentName, onToggleEnabled, onCl
</div>
</div>
</div>
)
);
}
@@ -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<TabKey>('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<TabKey>("general");
if (loading || !instance) {
return (
<div className="space-y-3">
<button onClick={onBack} className="text-xs text-text-muted hover:text-text-primary transition-colors cursor-pointer">
← {t('detail.back')}
<button
onClick={onBack}
className="text-xs text-text-muted hover:text-text-primary transition-colors cursor-pointer"
>
← {t("detail.back")}
</button>
<div className="space-y-2">
{[1, 2, 3].map((i) => <div key={i} className="h-10 rounded-lg bg-surface-tertiary/50 animate-pulse" />)}
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-10 rounded-lg bg-surface-tertiary/50 animate-pulse"
/>
))}
</div>
</div>
)
);
}
// 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 (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<button onClick={onBack} className="text-xs text-text-muted hover:text-text-primary transition-colors shrink-0 cursor-pointer">
← {t('detail.back')}
</button>
<span className="text-sm font-medium text-text-primary truncate">{instance.display_name || instance.name}</span>
<span className="rounded-full px-1.5 py-0.5 text-[10px] bg-surface-tertiary text-text-secondary border border-border shrink-0">
{t(`channelTypes.${instance.channel_type}`)}
</span>
<span className={`w-1.5 h-1.5 rounded-full ${dotColor} shrink-0`} />
<span className="text-[11px] text-text-muted shrink-0">{statusText}</span>
<span className="text-[11px] text-text-muted shrink-0">· {agentName}</span>
<div className="min-w-0 space-y-1">
<div className="flex items-center gap-3 min-w-0">
<button
onClick={onBack}
className="text-xs text-text-muted hover:text-text-primary transition-colors shrink-0 cursor-pointer"
>
← {t("detail.back")}
</button>
<span className="text-sm font-medium text-text-primary truncate">
{instance.display_name || instance.name}
</span>
<span className="rounded-full px-1.5 py-0.5 text-[10px] bg-surface-tertiary text-text-secondary border border-border shrink-0">
{t(`channelTypes.${instance.channel_type}`)}
</span>
<span className={`w-1.5 h-1.5 rounded-full ${dotColor} shrink-0`} />
<span className="text-[11px] text-text-muted shrink-0">
{statusText}
</span>
<span className="text-[11px] text-text-muted shrink-0">
· {agentName}
</span>
</div>
{(status?.summary || status?.detail) && (
<div className="space-y-1">
{status?.summary && (
<div className="text-[11px] text-text-muted break-words">
{status.summary}
</div>
)}
{status?.detail && (
<div className="text-[11px] text-text-muted break-words">
{status.detail}
</div>
)}
{status?.checked_at && (
<div className="text-[11px] text-text-muted">
{t("detail.lastChecked", {
defaultValue: "Last checked: {{value}}",
value: new Date(status.checked_at).toLocaleString(),
})}
</div>
)}
</div>
)}
</div>
<button onClick={onDelete} className="px-2.5 py-1 text-[11px] border border-border rounded-lg text-error hover:bg-error/10 transition-colors cursor-pointer shrink-0">
{t('delete.confirmLabel')}
<button
onClick={onDelete}
className="px-2.5 py-1 text-[11px] border border-border rounded-lg text-error hover:bg-error/10 transition-colors cursor-pointer shrink-0"
>
{t("delete.confirmLabel")}
</button>
</div>
@@ -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 */}
<div>
{activeTab === 'general' && <ChannelGeneralTab instance={instance} agents={agents} onUpdate={updateInstance} />}
{activeTab === 'credentials' && <ChannelCredentialsTab instance={instance} onUpdate={updateInstance} />}
{activeTab === 'managers' && (
{activeTab === "general" && (
<ChannelGeneralTab
instance={instance}
agents={agents}
onUpdate={updateInstance}
/>
)}
{activeTab === "credentials" && (
<ChannelCredentialsTab
instance={instance}
onUpdate={updateInstance}
/>
)}
{activeTab === "managers" && (
<ChannelManagersTab
listManagerGroups={listManagerGroups}
listManagers={listManagers}
@@ -99,8 +195,10 @@ export function ChannelDetailPanel({ instanceId, status, onBack, onDelete }: Cha
listContacts={listContacts}
/>
)}
{activeTab === 'advanced' && <ChannelAdvancedTab instance={instance} onUpdate={updateInstance} />}
{activeTab === "advanced" && (
<ChannelAdvancedTab instance={instance} onUpdate={updateInstance} />
)}
</div>
</div>
)
);
}
@@ -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",
+65 -42
View File
@@ -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<string, string>
config: Record<string, unknown>
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<string, string>;
config: Record<string, unknown>;
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<string, string>
config: Record<string, unknown>
enabled: boolean
name: string;
displayName: string;
channelType: string;
agentId: string;
credentials: Record<string, string>;
config: Record<string, unknown>;
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;
}
+160 -30
View File
@@ -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.",
+7
View File
@@ -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"
+47 -2
View File
@@ -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.",
+7
View File
@@ -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ó",
+47 -2
View File
@@ -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/飞书 应用需要在开发者控制台启用这些权限。添加权限后请发布新版本。",
+7
View File
@@ -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": "无",
@@ -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 <DetailPageSkeleton tabs={4} />;
}
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 <DetailPageSkeleton tabs={4} />;
}
return (
<div>
<ChannelHeader
@@ -63,16 +217,129 @@ export function ChannelDetailPage({ instanceId, onBack, onDelete }: ChannelDetai
onBack={onBack}
onAdvanced={() => setAdvancedOpen(true)}
onDelete={handleDelete}
primaryAction={headerAction}
/>
<div className="p-3 sm:p-4">
<div className="max-w-4xl">
<div className="max-w-4xl space-y-4">
{showDiagnosticsCard && status && (
<div
className={cn(
"rounded-xl border p-4",
statusMeta.surfaceClass,
)}
>
<div className="grid gap-4 sm:grid-cols-[minmax(0,1fr)_220px]">
<div>
<div className="flex items-center gap-2 text-sm font-medium">
<AlertTriangle className="h-4 w-4" />
<span>
{t("detail.whatHappened", {
defaultValue: "What happened",
})}
</span>
</div>
<p className="mt-2 text-base font-semibold">
{status.summary || statusMeta.label}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{status.remediation?.headline || diagnosticsHint}
</p>
<div className="mt-4">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{t("detail.recommendedAction", {
defaultValue: "Recommended action",
})}
</p>
<p className="mt-2 text-sm font-medium">
{remediation?.label ||
t("actions.inspect", { defaultValue: "Inspect issue" })}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{diagnosticsHint}
</p>
{remediation && remediation.target !== "details" && (
<Button
size="sm"
onClick={handleRemediationAction}
className="mt-3 sm:hidden"
>
{remediation.label}
</Button>
)}
</div>
{status.detail && (
<details className="mt-4 rounded-lg border border-border/80 bg-background/60 p-3">
<summary className="cursor-pointer text-sm font-medium">
{t("detail.technicalDetail", {
defaultValue: "Technical detail",
})}
</summary>
<p className="mt-2 break-words text-xs text-muted-foreground">
{status.detail}
</p>
</details>
)}
</div>
<div>
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{t("detail.timeline.title", { defaultValue: "Timeline" })}
</p>
<div className="mt-3 space-y-2">
{timelineItems.length > 0 ? (
timelineItems.map((item) => (
<div
key={item.label}
className="flex items-start justify-between gap-4 rounded-lg bg-background/60 px-3 py-2"
>
<span className="text-xs text-muted-foreground">
{item.label}
</span>
<span className="text-right text-xs font-medium tabular-nums">
{item.value}
</span>
</div>
))
) : (
<div className="rounded-lg bg-background/60 px-3 py-2 text-xs text-muted-foreground">
{checkedLabel ||
t("detail.timeline.noData", {
defaultValue: "No recent channel checks recorded yet.",
})}
</div>
)}
</div>
</div>
</div>
</div>
)}
{neutralHealthNote && (
<div className="flex items-center gap-2 rounded-lg border border-emerald-200/70 bg-emerald-500/[0.04] px-3 py-2 text-sm dark:border-emerald-500/20 dark:bg-emerald-500/10">
<CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
<span className="text-muted-foreground">{neutralHealthNote}</span>
</div>
)}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full justify-start overflow-x-auto overflow-y-hidden">
<TabsTrigger value="general">{t("detail.tabs.general")}</TabsTrigger>
<TabsTrigger value="credentials">{t("detail.tabs.credentials")}</TabsTrigger>
{isTelegram && <TabsTrigger value="groups">{t("detail.tabs.groups")}</TabsTrigger>}
<TabsTrigger value="managers">{t("detail.tabs.managers")}</TabsTrigger>
<TabsTrigger value="general">
{t("detail.tabs.general")}
</TabsTrigger>
<TabsTrigger value="credentials">
{t("detail.tabs.credentials")}
</TabsTrigger>
{isTelegram && (
<TabsTrigger value="groups">
{t("detail.tabs.groups")}
</TabsTrigger>
)}
<TabsTrigger value="managers">
{t("detail.tabs.managers")}
</TabsTrigger>
</TabsList>
<TabsContent value="general" className="mt-4">
@@ -119,16 +386,33 @@ export function ChannelDetailPage({ instanceId, onBack, onDelete }: ChannelDetai
onUpdate={updateInstance}
/>
{ReauthDialog && (
<ReauthDialog
open={reauthOpen}
onOpenChange={setReauthOpen}
instanceId={instance.id}
instanceName={instance.display_name || instance.name}
onSuccess={() => {
setReauthOpen(false);
}}
/>
)}
{onDelete && (
<ConfirmDeleteDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={t("delete.title")}
description={t("delete.description", { name: instance.display_name || instance.name })}
description={t("delete.description", {
name: instance.display_name || instance.name,
})}
confirmValue={instance.display_name || instance.name}
confirmLabel={t("delete.confirmLabel")}
onConfirm={async () => {
onDelete({ id: instance.id, name: instance.display_name || instance.name });
onDelete({
id: instance.id,
name: instance.display_name || instance.name,
});
setDeleteOpen(false);
}}
/>
@@ -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 (
<TooltipProvider>
<div className="sticky top-0 z-10 flex items-center gap-2 border-b bg-card px-3 py-2 landscape-compact sm:px-4 sm:gap-3">
<Button variant="ghost" size="icon" onClick={onBack} className="shrink-0 size-9">
<ArrowLeft className="h-4 w-4" />
</Button>
<div className="sticky top-0 z-10 border-b bg-card/95 px-3 py-2 backdrop-blur landscape-compact sm:px-4">
<div className="flex items-start gap-2 sm:gap-3">
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="shrink-0 size-9"
>
<ArrowLeft className="h-4 w-4" />
</Button>
{/* Channel icon */}
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:h-12 sm:w-12">
<Radio className="h-5 w-5 sm:h-6 sm:w-6" />
</div>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary sm:h-12 sm:w-12">
<Radio className="h-5 w-5 sm:h-6 sm:w-6" />
</div>
{/* Channel info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<h2 className="truncate text-base font-semibold">{displayTitle}</h2>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-block h-2.5 w-2.5 shrink-0 rounded-full",
instance.enabled ? "bg-emerald-500" : "bg-muted-foreground/50",
)}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{instance.enabled ? t("enabled") : t("disabled")}
</TooltipContent>
</Tooltip>
{status && (
<Badge variant={status.running ? "success" : "secondary"} className="text-[10px]">
{status.running ? t("status.running") : t("status.stopped")}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<h2 className="truncate text-base font-semibold">{displayTitle}</h2>
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-block h-2.5 w-2.5 shrink-0 rounded-full",
instance.enabled
? "bg-emerald-500"
: "bg-muted-foreground/50",
)}
/>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{instance.enabled ? t("enabled") : t("disabled")}
</TooltipContent>
</Tooltip>
<Badge variant={statusMeta.badgeVariant} className="text-[10px]">
{statusMeta.label}
</Badge>
)}
{failureKind && <Badge variant="outline" className="text-[10px]">{failureKind}</Badge>}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<span className="font-mono text-[11px]">{instance.name}</span>
<span className="text-border">·</span>
<Badge variant="outline" className="text-[10px]">
{typeLabel}
</Badge>
<span className="text-border">·</span>
<span>{t("detail.agent", { name: agentName })}</span>
{checkedLabel && (
<>
<span className="text-border">·</span>
<span>{checkedLabel}</span>
</>
)}
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">
{summaryLine}
</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
<span className="font-mono text-[11px]">{instance.name}</span>
<span className="text-border">·</span>
<Badge variant="outline" className="text-[10px]">{typeLabel}</Badge>
<span className="text-border">·</span>
<span>{t("detail.agent", { name: agentName })}</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={onAdvanced}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3"
>
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">{t("detail.advanced")}</span>
</Button>
{primaryAction && (
<Button
size="sm"
onClick={primaryAction.onClick}
className="hidden shrink-0 sm:inline-flex"
>
{primaryAction.label}
</Button>
)}
{!instance.is_default && (
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3 text-muted-foreground hover:text-destructive"
onClick={onAdvanced}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("delete.title")}</span>
<Settings className="h-4 w-4" />
<span className="hidden sm:inline">{t("detail.advanced")}</span>
</Button>
)}
{!instance.is_default && (
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="shrink-0 gap-1.5 size-9 sm:w-auto sm:px-3 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
<span className="hidden sm:inline">{t("delete.title")}</span>
</Button>
)}
</div>
</div>
</TooltipProvider>
);
+133 -70
View File
@@ -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 (
<button
type="button"
onClick={onClick}
className="flex w-full cursor-pointer items-center gap-3 rounded-lg border bg-card px-4 py-3 text-left transition-all hover:border-primary/30 hover:shadow-sm"
<div
className={cn(
"rounded-xl border bg-card shadow-sm transition-colors hover:border-primary/30",
statusMeta.attention && statusMeta.surfaceClass,
)}
>
{/* Icon */}
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Radio className="h-4 w-4" />
</div>
<div className="flex items-stretch gap-2 p-3 sm:p-4">
<button
type="button"
onClick={onClick}
className="flex-1 text-left"
>
<div className="grid gap-3 lg:grid-cols-[minmax(0,1.05fr)_minmax(0,1fr)_minmax(180px,0.7fr)]">
<div className="flex min-w-0 gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Radio className="h-4 w-4" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-semibold">
{displayName}
</span>
<span
className={cn(
"inline-block h-2 w-2 shrink-0 rounded-full",
statusMeta.dotClass,
)}
/>
<Badge variant="outline" className="text-[11px]">
{channelTypeLabels[instance.channel_type] || instance.channel_type}
</Badge>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{instance.name}</span>
<span className="text-border">·</span>
<span className="truncate">{agentName}</span>
</div>
</div>
</div>
{/* Name + slug */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold">{displayName}</span>
<span className={`inline-block h-2 w-2 shrink-0 rounded-full ${
!instance.enabled ? "bg-muted-foreground/40"
: isRunning ? "bg-emerald-500"
: "bg-amber-500"
}`} />
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={statusMeta.badgeVariant}>{statusMeta.label}</Badge>
{failureKind && <Badge variant="outline">{failureKind}</Badge>}
</div>
<p className="mt-2 truncate text-sm font-medium">{summaryLine}</p>
{streakLabel && (
<p className="mt-1 truncate text-xs text-muted-foreground">
{streakLabel}
</p>
)}
</div>
<div className="min-w-0">
<p className="text-[11px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
{t("list.nextStep", { defaultValue: "Next step" })}
</p>
<p className="mt-2 truncate text-sm font-medium">{nextStepLabel}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">
{nextStepHint}
</p>
</div>
</div>
</button>
<div className="flex shrink-0 items-start gap-1">
{onAuth && supportsReauth && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-primary"
onClick={(e) => {
e.stopPropagation();
onAuth();
}}
>
<QrCode className="h-3.5 w-3.5" />
</Button>
)}
{onDelete && !instance.is_default && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
{instance.display_name && (
<div className="truncate text-xs text-muted-foreground">{instance.name}</div>
)}
</div>
{/* Type */}
<div className="hidden shrink-0 sm:block">
<Badge variant="secondary" className="text-[11px]">
{channelTypeLabels[instance.channel_type] || instance.channel_type}
</Badge>
</div>
{/* Status text */}
<div className="hidden shrink-0 text-xs text-muted-foreground sm:block sm:w-16">
{!instance.enabled
? t("disabled")
: isRunning ? t("status.running") : t("status.stopped")}
</div>
{/* Agent */}
<div className="hidden shrink-0 text-xs text-muted-foreground md:block md:w-28 md:truncate">
{agentName}
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
{onAuth && channelsWithAuth.has(instance.channel_type) && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-primary"
onClick={(e) => { e.stopPropagation(); onAuth(); }}
>
<QrCode className="h-3.5 w-3.5" />
</Button>
)}
{onDelete && !instance.is_default && (
<Button
variant="ghost"
size="xs"
className="text-muted-foreground hover:text-destructive"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
</button>
</div>
);
}
+16 -5
View File
@@ -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 (
<div className="p-4 sm:p-6 pb-10">
@@ -154,11 +165,11 @@ export function ChannelsPage() {
) : (
<>
<div className="mt-4 flex flex-col gap-2">
{instances.map((inst) => (
{sortedInstances.map((inst) => (
<ChannelListRow
key={inst.id}
instance={inst}
status={getStatus(inst.name)}
status={getStatus(inst)}
agentName={getAgentName(inst.agent_id)}
onClick={() => navigate(`/channels/${inst.id}`)}
onAuth={channelsWithAuth.has(inst.channel_type) ? () => setQrTarget(inst) : undefined}
@@ -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<string, string> = {
telegram: "Telegram",
@@ -18,8 +25,332 @@ const channelTypeLabels: Record<string, string> = {
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<string, ChannelStatus>;
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={
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("refresh")}
<Button
variant="outline"
size="sm"
onClick={refresh}
disabled={spinning}
className="gap-1"
>
<RefreshCw
className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")}
/>{" "}
{t("refresh")}
</Button>
}
/>
@@ -59,28 +404,35 @@ export function ChannelsStatusView({ channels, loading, spinning, refresh }: Cha
/>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{entries.map(([name, status]: [string, ChannelStatus]) => (
<div key={name} className="rounded-lg border p-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">
{channelTypeLabels[name] || name}
</h4>
{status.enabled ? (
<Badge variant="success">{t("enabled")}</Badge>
) : (
<Badge variant="secondary">{t("disabled")}</Badge>
{entries.map(([name, status]) => {
const meta = getChannelStatusMeta(status, status.enabled, t);
const checked = getChannelCheckedLabel(status, t);
const failureKind = getChannelFailureKindLabel(
status.failure_kind,
t,
);
return (
<div
key={name}
className={`rounded-lg border p-4 ${meta.surfaceClass}`}
>
<div className="flex items-center justify-between gap-3">
<h4 className="text-sm font-medium">
{channelTypeLabels[name] || name}
</h4>
<Badge variant={meta.badgeVariant}>{meta.label}</Badge>
</div>
{status.summary && (
<p className="mt-3 text-sm font-medium">{status.summary}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
{failureKind && <Badge variant="outline">{failureKind}</Badge>}
{checked && <span>{checked}</span>}
</div>
</div>
<div className="mt-3 flex items-center gap-2 text-sm">
<span
className={`h-2 w-2 rounded-full ${status.running ? "bg-green-500" : "bg-muted-foreground"}`}
/>
<span className="text-muted-foreground">
{status.running ? t("status.running") : t("status.stopped")}
</span>
</div>
</div>
))}
);
})}
</div>
)}
</div>
@@ -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();
+47 -6
View File
@@ -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
}
/>
</div>
+114 -15
View File
@@ -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 (
<Card className="gap-4">
<CardHeader>
@@ -82,7 +99,9 @@ export function SystemHealthCard({
{health?.version && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Tag className="h-3 w-3" />
<span className="font-medium">{cleanVersion(health.version)}</span>
<span className="font-medium">
{cleanVersion(health.version)}
</span>
{health.updateAvailable === false && (
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
)}
@@ -167,22 +186,102 @@ export function SystemHealthCard({
{channelEntries.length > 0 && (
<div className="border-t pt-4">
<p className="mb-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("systemHealth.channels")}
</p>
<div className="flex flex-wrap gap-1.5">
{channelEntries.map(([name, ch]) => (
<span
key={name}
className="inline-flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1 text-xs"
>
<span
className={`h-1.5 w-1.5 rounded-full ${ch.running ? "bg-emerald-500" : "bg-red-400"}`}
/>
{name}
<div className="mb-2 flex items-center justify-between gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{t("systemHealth.channels")}
</p>
{(degradedCount > 0 || failedCount > 0) && (
<span className="text-xs text-muted-foreground">
{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,
})}
</span>
))}
)}
</div>
<div className="flex flex-wrap gap-1.5">
{channelEntries.map(([name, ch]) => {
const meta = getChannelStatusMeta(ch, ch.enabled, t);
return (
<span
key={name}
className="inline-flex items-center gap-1.5 rounded-md bg-muted/50 px-2 py-1 text-xs"
>
<span
className={`h-1.5 w-1.5 rounded-full ${meta.dotClass}`}
/>
{name}
</span>
);
})}
</div>
{attentionPreview.length > 0 && (
<div className="mt-3 rounded-lg border border-amber-200/70 bg-amber-500/[0.05] p-3 dark:border-amber-500/20 dark:bg-amber-500/10">
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t("systemHealth.needsAttention", {
defaultValue: "Needs attention",
})}
</p>
<span className="text-xs text-muted-foreground">
{t("systemHealth.channelsNeedingAttention", {
defaultValue: "{{count}} channels",
count: attentionEntries.length,
})}
</span>
</div>
<div className="space-y-2">
{attentionPreview.map(([name, ch]) => {
const meta = getChannelStatusMeta(ch, ch.enabled, t);
const checked = formatRelativeTime(ch.checked_at);
return (
<div
key={name}
className="flex items-start gap-2 text-sm"
>
<span
className={`mt-1.5 h-2 w-2 shrink-0 rounded-full ${meta.dotClass}`}
/>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{name}</span>
<span className="text-xs text-muted-foreground">
{meta.label}
</span>
{checked && (
<span className="text-xs text-muted-foreground">
{checked}
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">
{ch.summary || meta.label}
</p>
</div>
</div>
);
})}
{hiddenAttentionCount > 0 && (
<p className="text-xs text-muted-foreground">
{t("systemHealth.moreAttention", {
defaultValue: "+{{count}} more",
count: hiddenAttentionCount,
})}
</p>
)}
</div>
</div>
)}
</div>
)}
</CardContent>
+3 -4
View File
@@ -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<string, ChannelStatusEntry>;
+28
View File
@@ -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;