diff --git a/web/src/App.tsx b/web/src/App.tsx index 382feafe7df..aef3148b747 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = { "/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 }, diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 5b77ea2e924..6202b9f28c0 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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( + `/api/messaging/platforms/${encodeURIComponent(id)}/test`, + { method: "POST" }, + ), + // Gateway / update actions restartGateway: () => fetchJSON("/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; + clear_env?: string[]; +} + +export interface MessagingPlatformTestResult { + ok: boolean; + state: string; + message: string; +} + export interface PairingUser { platform: string; user_id: string; diff --git a/web/src/pages/ChannelsPage.tsx b/web/src/pages/ChannelsPage.tsx new file mode 100644 index 00000000000..4320d5f86a7 --- /dev/null +++ b/web/src/pages/ChannelsPage.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const { toast, showToast } = useToast(); + const { setEnd } = usePageHeader(); + + // Config modal state + const [editing, setEditing] = useState(null); + const [draftEnv, setDraftEnv] = useState>({}); + 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(null); + const [testingId, setTestingId] = useState(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 = {}; + 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 = {}; + 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( + , + ); + 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 ( +
+ +
+ ); + } + + return ( +
+ + + {/* Restart banner */} + {restartNeeded && ( + + +
+ + + Changes are saved. Restart the gateway for them to take effect. + +
+ +
+
+ )} + + {!gatewayRunning && !restartNeeded && ( + + + + + The gateway is not running. Configure channels here, then start the + gateway with hermes gateway start{" "} + (or the Restart button above). + + + + )} + +

+ {configured} of {platforms.length} channels configured. Credentials are + written to ~/.hermes/.env; the + gateway connects each enabled channel on its next restart. +

+ + {/* Config modal */} + {editing && ( +
e.target === e.currentTarget && setEditing(null)} + role="dialog" + aria-modal="true" + aria-labelledby="channel-config-title" + > +
+ + +
+

+ Configure {editing.name} +

+ {editing.docs_url && ( + + Setup guide + + )} +
+ +
+

+ {editing.description} +

+ {editing.env_vars.map((field: MessagingPlatformEnvVar) => ( +
+ + {field.description && ( + + {field.description} + + )} + + setDraftEnv((prev) => ({ ...prev, [field.key]: e.target.value })) + } + /> +
+ ))} + +
+ + +
+
+
+
+ )} + + {/* Platform list */} +
+ {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 ( + + +
+ +
+
+ + {platform.name} + + {badge.label} +
+ + {platform.description} + + {platform.error_message && ( + + {platform.error_message} + + )} +
+
+ +
+
+ {busy ? ( + + ) : ( + void handleToggle(platform)} + aria-label={`Enable ${platform.name}`} + /> + )} +
+ + +
+
+
+ ); + })} +
+
+ ); +} diff --git a/website/docs/user-guide/features/web-dashboard.md b/website/docs/user-guide/features/web-dashboard.md index 68202af747c..67cbe0e2a74 100644 --- a/website/docs/user-guide/features/web-dashboard.md +++ b/website/docs/user-guide/features/web-dashboard.md @@ -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}` | diff --git a/website/static/img/dashboard/admin-channels.png b/website/static/img/dashboard/admin-channels.png new file mode 100644 index 00000000000..9d067603ee0 Binary files /dev/null and b/website/static/img/dashboard/admin-channels.png differ