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.
430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
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>
|
|
);
|
|
}
|