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
No files matched your search
|
After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 174 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 147 KiB |
@@ -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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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ó",
|
||||
|
||||
@@ -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/飞书 应用需要在开发者控制台启用这些权限。添加权限后请发布新版本。",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||