feat(dashboard): Channels page — set up every gateway messaging channel from the browser (#37211)

The /api/messaging/platforms endpoints (catalog, configure, test) shipped
with the desktop app but never got a dashboard UI; the recent admin-panel
PRs covered MCP/webhooks/hooks/system but skipped messaging channels. This
adds the missing page so all 20+ channels (Telegram, Discord, Slack, Matrix,
Mattermost, WhatsApp, Signal, BlueBubbles, Email, SMS, DingTalk, Feishu,
WeCom, WeChat, QQ Bot, Yuanbao, plugin platforms, etc.) can be configured,
enabled/disabled, tested, and connected entirely from the browser.

- web/src/pages/ChannelsPage.tsx: per-platform list with live status, enable
  Switch, Test, and a Configure modal that renders each platform's exact
  setup fields (secrets masked, required validated, redacted display).
- web/src/lib/api.ts: MessagingPlatform types + get/update/test client fns.
- web/src/App.tsx: /channels route + nav tab (Radio icon, after MCP).
- docs: Channels section + REST endpoints + screenshot.

Frontend-only — reuses the existing env-write + config-enable backend, which
auto-enables a platform once its required env vars are present and the
gateway restarts. No core changes, no new tool schema.
This commit is contained in:
Teknium
2026-06-01 23:41:35 -07:00
committed by GitHub
parent 15cb4e2279
commit 3c1d066a8a
5 changed files with 514 additions and 1 deletions

View File

@ -37,6 +37,7 @@ import {
PanelLeftOpen,
Plug,
Puzzle,
Radio,
RotateCw,
Settings,
Shield,
@ -77,6 +78,7 @@ import SkillsPage from "@/pages/SkillsPage";
import PluginsPage from "@/pages/PluginsPage";
import McpPage from "@/pages/McpPage";
import PairingPage from "@/pages/PairingPage";
import ChannelsPage from "@/pages/ChannelsPage";
import WebhooksPage from "@/pages/WebhooksPage";
import SystemPage from "@/pages/SystemPage";
import ChatPage from "@/pages/ChatPage";
@ -130,6 +132,7 @@ const BUILTIN_ROUTES_CORE: Record<string, ComponentType> = {
"/plugins": PluginsPage,
"/mcp": McpPage,
"/pairing": PairingPage,
"/channels": ChannelsPage,
"/webhooks": WebhooksPage,
"/system": SystemPage,
"/profiles": ProfilesPage,
@ -170,6 +173,7 @@ const BUILTIN_NAV_REST: NavItem[] = [
{ path: "/skills", labelKey: "skills", label: "Skills", icon: Package },
{ path: "/plugins", labelKey: "plugins", label: "Plugins", icon: Puzzle },
{ path: "/mcp", label: "MCP", icon: Plug },
{ path: "/channels", label: "Channels", icon: Radio },
{ path: "/webhooks", label: "Webhooks", icon: Webhook },
{ path: "/pairing", label: "Pairing", icon: ShieldCheck },
{ path: "/profiles", labelKey: "profiles", label: "Profiles", icon: Users },

View File

@ -460,6 +460,24 @@ export const api = {
);
},
// Messaging platforms (gateway channels)
getMessagingPlatforms: () =>
fetchJSON<{ platforms: MessagingPlatform[] }>("/api/messaging/platforms"),
updateMessagingPlatform: (id: string, body: MessagingPlatformUpdate) =>
fetchJSON<{ ok: boolean; platform: string }>(
`/api/messaging/platforms/${encodeURIComponent(id)}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
),
testMessagingPlatform: (id: string) =>
fetchJSON<MessagingPlatformTestResult>(
`/api/messaging/platforms/${encodeURIComponent(id)}/test`,
{ method: "POST" },
),
// Gateway / update actions
restartGateway: () =>
fetchJSON<ActionResponse>("/api/gateway/restart", { method: "POST" }),
@ -838,6 +856,50 @@ export interface McpTestResult {
tools: Array<{ name: string; description: string }>;
}
export interface MessagingPlatformEnvVar {
key: string;
required: boolean;
is_set: boolean;
redacted_value: string | null;
description: string;
prompt: string;
url: string | null;
is_password: boolean;
advanced: boolean;
}
export interface MessagingPlatform {
id: string;
name: string;
description: string;
docs_url: string;
enabled: boolean;
configured: boolean;
gateway_running: boolean;
/**
* "connected" | "disabled" | "not_configured" | "pending_restart" |
* "gateway_stopped" | "disconnected" | "fatal" | string
*/
state: string;
error_code: string | null;
error_message: string | null;
updated_at: string | null;
home_channel: { platform: string; chat_id: string; name: string; thread_id?: string } | null;
env_vars: MessagingPlatformEnvVar[];
}
export interface MessagingPlatformUpdate {
enabled?: boolean;
env?: Record<string, string>;
clear_env?: string[];
}
export interface MessagingPlatformTestResult {
ok: boolean;
state: string;
message: string;
}
export interface PairingUser {
platform: string;
user_id: string;

View File

@ -0,0 +1,429 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from "react";
import {
AlertTriangle,
CheckCircle2,
ExternalLink,
PlugZap,
Radio,
RotateCw,
Settings2,
WifiOff,
X,
} from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Switch } from "@nous-research/ui/ui/components/switch";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { api } from "@/lib/api";
import type {
MessagingPlatform,
MessagingPlatformEnvVar,
MessagingPlatformUpdate,
} from "@/lib/api";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { usePageHeader } from "@/contexts/usePageHeader";
import { cn, themedBody } from "@/lib/utils";
// State → badge mapping. The backend emits a small, fixed vocabulary plus
// whatever the live gateway runtime reports (connected/disconnected/fatal).
const STATE_BADGE: Record<
string,
{ tone: "success" | "warning" | "destructive" | "secondary" | "outline"; label: string }
> = {
connected: { tone: "success", label: "Connected" },
pending_restart: { tone: "warning", label: "Restart to apply" },
gateway_stopped: { tone: "warning", label: "Gateway stopped" },
disconnected: { tone: "warning", label: "Disconnected" },
not_configured: { tone: "outline", label: "Not configured" },
disabled: { tone: "secondary", label: "Disabled" },
fatal: { tone: "destructive", label: "Error" },
};
function stateBadge(state: string) {
return STATE_BADGE[state] ?? { tone: "outline" as const, label: state };
}
export default function ChannelsPage() {
const [platforms, setPlatforms] = useState<MessagingPlatform[]>([]);
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { setEnd } = usePageHeader();
// Config modal state
const [editing, setEditing] = useState<MessagingPlatform | null>(null);
const [draftEnv, setDraftEnv] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const closeEdit = useCallback(() => setEditing(null), []);
const editModalRef = useModalBehavior({ open: editing !== null, onClose: closeEdit });
// Per-card busy + restart-needed tracking
const [togglingId, setTogglingId] = useState<string | null>(null);
const [testingId, setTestingId] = useState<string | null>(null);
const [restartNeeded, setRestartNeeded] = useState(false);
const [restarting, setRestarting] = useState(false);
const gatewayRunning = platforms.length > 0 && platforms[0].gateway_running;
const load = useCallback(() => {
return api
.getMessagingPlatforms()
.then((res) => setPlatforms(res.platforms))
.catch((e) => showToast(`Error: ${e}`, "error"));
}, [showToast]);
useEffect(() => {
load().finally(() => setLoading(false));
}, [load]);
const openConfig = (platform: MessagingPlatform) => {
const initial: Record<string, string> = {};
platform.env_vars.forEach((v) => {
initial[v.key] = "";
});
setDraftEnv(initial);
setEditing(platform);
};
const handleSave = async () => {
if (!editing) return;
// Only send fields the user actually filled in — leaving a field blank
// preserves the existing value rather than clobbering it.
const env: Record<string, string> = {};
Object.entries(draftEnv).forEach(([k, v]) => {
if (v.trim()) env[k] = v.trim();
});
if (Object.keys(env).length === 0) {
showToast("Nothing to save — fill in at least one field.", "error");
return;
}
const missing = editing.env_vars.filter(
(v) => v.required && !v.is_set && !env[v.key],
);
if (missing.length > 0) {
showToast(`${missing[0].prompt || missing[0].key} is required`, "error");
return;
}
setSaving(true);
try {
const body: MessagingPlatformUpdate = { env, enabled: true };
await api.updateMessagingPlatform(editing.id, body);
showToast(`${editing.name} saved`, "success");
setEditing(null);
setRestartNeeded(true);
await load();
} catch (e) {
showToast(`Failed to save: ${e}`, "error");
} finally {
setSaving(false);
}
};
const handleToggle = async (platform: MessagingPlatform) => {
const next = !platform.enabled;
setTogglingId(platform.id);
try {
await api.updateMessagingPlatform(platform.id, { enabled: next });
setPlatforms((prev) =>
prev.map((p) =>
p.id === platform.id
? { ...p, enabled: next, state: next ? "pending_restart" : "disabled" }
: p,
),
);
setRestartNeeded(true);
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setTogglingId(null);
}
};
const handleTest = async (platform: MessagingPlatform) => {
setTestingId(platform.id);
try {
const res = await api.testMessagingPlatform(platform.id);
showToast(`${platform.name}: ${res.message}`, res.ok ? "success" : "error");
} catch (e) {
showToast(`Error: ${e}`, "error");
} finally {
setTestingId(null);
}
};
const handleRestart = async () => {
setRestarting(true);
try {
await api.restartGateway();
showToast("Gateway restarting…", "success");
setRestartNeeded(false);
// Give the gateway a moment to come up, then refresh status.
setTimeout(() => void load(), 4000);
} catch (e) {
showToast(`Failed to restart: ${e}`, "error");
} finally {
setRestarting(false);
}
};
useLayoutEffect(() => {
setEnd(
<Button
className="uppercase"
size="sm"
onClick={handleRestart}
disabled={restarting}
prefix={restarting ? <Spinner /> : <RotateCw className="h-4 w-4" />}
>
{restarting ? "Restarting…" : "Restart gateway"}
</Button>,
);
return () => setEnd(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setEnd, restarting]);
const configured = useMemo(
() => platforms.filter((p) => p.configured).length,
[platforms],
);
if (loading) {
return (
<div className="flex items-center justify-center py-24">
<Spinner className="text-2xl text-primary" />
</div>
);
}
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
{/* Restart banner */}
{restartNeeded && (
<Card className="border-warning/50">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4 shrink-0 text-warning" />
<span>
Changes are saved. Restart the gateway for them to take effect.
</span>
</div>
<Button
size="sm"
className="uppercase shrink-0"
onClick={handleRestart}
disabled={restarting}
prefix={restarting ? <Spinner /> : <RotateCw className="h-4 w-4" />}
>
{restarting ? "Restarting…" : "Restart now"}
</Button>
</CardContent>
</Card>
)}
{!gatewayRunning && !restartNeeded && (
<Card className="border-border">
<CardContent className="flex items-center gap-2 p-4 text-sm text-muted-foreground">
<WifiOff className="h-4 w-4 shrink-0" />
<span>
The gateway is not running. Configure channels here, then start the
gateway with <code className="font-courier">hermes gateway start</code>{" "}
(or the Restart button above).
</span>
</CardContent>
</Card>
)}
<p className="text-xs text-muted-foreground">
{configured} of {platforms.length} channels configured. Credentials are
written to <code className="font-courier">~/.hermes/.env</code>; the
gateway connects each enabled channel on its next restart.
</p>
{/* Config modal */}
{editing && (
<div
ref={editModalRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => e.target === e.currentTarget && setEditing(null)}
role="dialog"
aria-modal="true"
aria-labelledby="channel-config-title"
>
<div
className={cn(
themedBody,
"relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col max-h-[90vh]",
)}
>
<Button
ghost
size="icon"
onClick={() => setEditing(null)}
className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
aria-label="Close"
>
<X />
</Button>
<header className="p-5 pb-3 border-b border-border">
<h2
id="channel-config-title"
className="font-mondwest text-display text-base tracking-wider"
>
Configure {editing.name}
</h2>
{editing.docs_url && (
<a
href={editing.docs_url}
target="_blank"
rel="noopener noreferrer"
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
Setup guide <ExternalLink className="h-3 w-3" />
</a>
)}
</header>
<div className="p-5 grid gap-4 overflow-y-auto">
<p className="text-xs text-muted-foreground">
{editing.description}
</p>
{editing.env_vars.map((field: MessagingPlatformEnvVar) => (
<div className="grid gap-1.5" key={field.key}>
<Label htmlFor={`field-${field.key}`}>
{field.prompt || field.key}
{field.required ? " *" : ""}
</Label>
{field.description && (
<span className="text-xs text-muted-foreground">
{field.description}
</span>
)}
<Input
id={`field-${field.key}`}
type={field.is_password ? "password" : "text"}
placeholder={
field.is_set
? field.redacted_value || "•••••• (set — leave blank to keep)"
: field.key
}
value={draftEnv[field.key] ?? ""}
onChange={(e) =>
setDraftEnv((prev) => ({ ...prev, [field.key]: e.target.value }))
}
/>
</div>
))}
<div className="flex justify-end gap-2 pt-1">
<Button ghost size="sm" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button
className="uppercase"
size="sm"
onClick={handleSave}
disabled={saving}
prefix={saving ? <Spinner /> : undefined}
>
{saving ? "Saving…" : "Save & enable"}
</Button>
</div>
</div>
</div>
</div>
)}
{/* Platform list */}
<div className="grid gap-3">
{platforms.map((platform) => {
const badge = stateBadge(platform.state);
const busy = togglingId === platform.id;
const StateIcon =
platform.state === "connected"
? CheckCircle2
: platform.state === "fatal"
? AlertTriangle
: Radio;
return (
<Card key={platform.id} className="border-border">
<CardContent className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-start gap-3 min-w-0">
<StateIcon
className={cn(
"h-5 w-5 shrink-0 mt-0.5",
platform.state === "connected"
? "text-success"
: platform.state === "fatal"
? "text-destructive"
: "text-muted-foreground",
)}
/>
<div className="flex flex-col gap-0.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mondwest normal-case text-sm font-medium">
{platform.name}
</span>
<Badge tone={badge.tone}>{badge.label}</Badge>
</div>
<span className="text-xs text-muted-foreground">
{platform.description}
</span>
{platform.error_message && (
<span className="text-xs text-destructive">
{platform.error_message}
</span>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0 self-start sm:self-center">
<div className="flex items-center gap-1.5">
{busy ? (
<Spinner className="text-sm" />
) : (
<Switch
checked={platform.enabled}
onCheckedChange={() => void handleToggle(platform)}
aria-label={`Enable ${platform.name}`}
/>
)}
</div>
<Button
ghost
size="sm"
onClick={() => handleTest(platform)}
disabled={testingId === platform.id}
prefix={
testingId === platform.id ? (
<Spinner />
) : (
<PlugZap className="h-4 w-4" />
)
}
>
Test
</Button>
<Button
size="sm"
className="uppercase"
onClick={() => openConfig(platform)}
prefix={<Settings2 className="h-4 w-4" />}
>
Configure
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}

View File

@ -240,6 +240,21 @@ onboards Telegram/Discord/etc. users to a paired gateway. Full parity with
![Pairing admin page](/img/dashboard/admin-pairing.png)
### Channels
Connect Hermes to any messaging platform from the browser — full parity with
`hermes setup gateway`. The page lists every supported channel (Telegram,
Discord, Slack, Matrix, Mattermost, WhatsApp, Signal, BlueBubbles/iMessage,
Email, SMS/Twilio, DingTalk, Feishu/Lark, WeCom, WeChat, QQ Bot, Yuanbao, plus
the API server and webhook endpoints) with its live connection status.
- **Configure** — open a per-platform form with exactly the fields that channel needs (bot token, app token, server URL, allowlist, etc.). Secrets render as password inputs and are stored redacted; leaving a field blank keeps the existing value. Required fields are marked and validated. A "Setup guide" link points to the platform's credential docs.
- **Enable / disable** — toggle a channel on or off. The credential stays on disk; only the active state changes.
- **Test** — check whether the channel is configured, enabled, and reporting a live connection from the gateway.
- **Restart gateway** — credentials are written to `~/.hermes/.env` and the enabled flag to `config.yaml`; the gateway connects each enabled channel on its next restart, which you can trigger right from the page.
![Channels admin page — every messaging platform with status, enable toggles, and per-platform setup forms](/img/dashboard/admin-channels.png)
### System
A consolidated administration panel for installation-wide operations:
@ -381,7 +396,7 @@ Returns all toolsets with their label, description, tools list, and active/confi
### Admin endpoints
These power the MCP, Webhooks, Pairing, and System pages. All sit behind the
These power the MCP, Channels, Webhooks, Pairing, and System pages. All sit behind the
same auth gate as the rest of `/api/`.
| Method & path | Purpose |
@ -393,6 +408,9 @@ same auth gate as the rest of `/api/`.
| `DELETE /api/mcp/servers/{name}` | Remove a server |
| `GET /api/mcp/catalog` | Browse the Nous-approved MCP catalog |
| `POST /api/mcp/catalog/install` | Install a catalog entry (with required env) |
| `GET /api/messaging/platforms` | List every messaging channel with status + per-platform setup fields |
| `PUT /api/messaging/platforms/{id}` | Configure a channel. Body: `{enabled?, env?, clear_env?}` (env writes to `.env`, enabled to `config.yaml`) |
| `POST /api/messaging/platforms/{id}/test` | Report whether a channel is configured, enabled, and connected |
| `GET /api/pairing` | List pending + approved messaging users |
| `POST /api/pairing/approve` | Approve a code. Body: `{platform, code}` |
| `POST /api/pairing/revoke` | Revoke a user. Body: `{platform, user_id}` |

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 KiB