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:
@ -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 },
|
||||
|
||||
@ -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;
|
||||
|
||||
429
web/src/pages/ChannelsPage.tsx
Normal file
429
web/src/pages/ChannelsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user