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>
);
}