From 6d1e08b2bcabda35e67b17a35fe1f1a82705a551 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 26 Jul 2026 14:18:21 -0700 Subject: [PATCH] =?UTF-8?q?fix(dashboard):=20QA=20pass=20=E2=80=94=20log?= =?UTF-8?q?=20colors,=20nameless=20channels,=20config=20bool,=20UX=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion fixes from a full dashboard QA pass (every page dogfooded live), on top of the cherry-picked #31863 header-slot fix: - ChatPage: harden the header-slot effect further — useLayoutEffect and never write the slot while inactive, so the handoff commentary and ownership rule live next to the code. - LogsPage: level classification used raw substring matching, so INFO lines carrying 'parse_errors=0' (or paths like errors.log) rendered red. New unit-tested classifier (web/src/lib/log-classify.ts) anchors on the hermes_logging level token with a word-boundary fallback. - Channels API: plugin platforms (irc, ntfy, photon, teams, …) rendered as nameless title-cased cards ('Irc', 'Ntfy') with empty descriptions. Two root causes: (1) plugin discovery never ran in the dashboard server process, so plugin_entries() was empty; (2) Platform enum pseudo-members claimed plugin ids before the registry could attach labels. The catalog now discovers plugins explicitly and resolves plugin metadata first; added descriptions + docs links for bundled plugin platforms and the msgraph_webhook / whatsapp_cloud / relay enum members. Regression test sabotage-verified against the old enum-first ordering. - Config schema: updates.refresh_cua_driver declared type 'bool' (schema vocabulary is 'boolean'), so the switch rendered as a text input holding 'true'. - Page titles: '/mcp' rendered as 'Mcp' via the naive capitalize fallback; literal-label table now covers MCP/Files/Channels/Webhooks/ Pairing/System (unit-tested). - AuthWidget: skip the guaranteed-401 /api/auth/me probe in loopback mode — every dashboard load logged a console error for nothing. - Model picker: with no filter, providers that actually have models float above the wall of '0 models' rows. - Cron: empty state now carries an actionable Create button. --- hermes_cli/web_server.py | 88 ++++++++++++++++++++---- tests/hermes_cli/test_web_server.py | 36 ++++++++++ web/src/components/AuthWidget.tsx | 12 +++- web/src/components/ModelPickerDialog.tsx | 25 ++++--- web/src/lib/log-classify.test.ts | 50 ++++++++++++++ web/src/lib/log-classify.ts | 36 ++++++++++ web/src/lib/resolve-page-title.test.ts | 56 +++++++++++++++ web/src/lib/resolve-page-title.ts | 16 +++++ web/src/pages/ChatPage.tsx | 14 ++-- web/src/pages/CronPage.tsx | 16 ++++- web/src/pages/LogsPage.tsx | 17 ++--- 11 files changed, 325 insertions(+), 41 deletions(-) create mode 100644 web/src/lib/log-classify.test.ts create mode 100644 web/src/lib/log-classify.ts create mode 100644 web/src/lib/resolve-page-title.test.ts diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index d9b7a1a56bf8..5ae0010ca472 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -922,7 +922,7 @@ _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "options": ["stash", "discard"], }, "updates.refresh_cua_driver": { - "type": "bool", + "type": "boolean", "description": ( "Refresh an already-installed cua-driver during hermes update. " "Disable this on non-admin macOS accounts where /Applications is " @@ -8202,8 +8202,36 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { # plugin registry. Only the docs link needs an override here so the # Channels page can point at the Microsoft Teams setup guide. "teams": { + "description": "Connect Hermes to Microsoft Teams chats via the Bot Framework.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/teams", }, + # Bundled platform plugins: name comes from the plugin registry label; + # give each a human description (the registry's install_hint is a + # dependency note, not a description) and a docs link. + "irc": { + "description": "Relay messages between an IRC channel (or DMs) and Hermes.", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/irc", + }, + "line": { + "description": "Use Hermes from LINE via the LINE Messaging API webhook.", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/line", + }, + "ntfy": { + "description": "Chat with Hermes over ntfy push topics (ntfy.sh or self-hosted).", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/ntfy", + }, + "photon": { + "description": "Use Hermes through iMessage via Photon's managed Spectrum platform.", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/photon", + }, + "raft": { + "description": "Join a Raft workspace as an external agent.", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/raft", + }, + "simplex": { + "description": "Talk to Hermes over SimpleX Chat via a local simplex-chat daemon.", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/simplex", + }, "yuanbao": { "name": "Yuanbao (元宝)", "description": "Connect Hermes to Tencent Yuanbao.", @@ -8230,6 +8258,23 @@ _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { "env_vars": ("WEBHOOK_ENABLED", "WEBHOOK_PORT", "WEBHOOK_SECRET"), "required_env": (), }, + "msgraph_webhook": { + "name": "Microsoft Graph Webhook", + "description": "Receive Microsoft Graph change notifications (Teams meetings, Outlook, …).", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/msgraph-webhook", + "required_env": (), + }, + "whatsapp_cloud": { + "name": "WhatsApp Cloud API", + "description": "Use Hermes via Meta's hosted WhatsApp Cloud API (no local bridge).", + "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/whatsapp-cloud", + }, + "relay": { + "name": "Relay (experimental)", + "description": "Generic relay adapter fronted by the Hermes Relay connector.", + "docs_url": "", + "required_env": (), + }, } # Display order: well-known platforms surface first; unknown plugins fall to @@ -8411,6 +8456,28 @@ def _messaging_platform_catalog() -> tuple[dict[str, Any], ...]: """ from gateway.config import Platform + # Resolve plugin entries FIRST. Plugin platforms (irc, ntfy, photon, …) + # leak into ``Platform.__members__`` as pseudo-members the moment any + # earlier code path calls ``Platform("")`` — and iterating the + # enum first would then claim them with no plugin metadata, rendering + # nameless "Irc"/"Ntfy" cards with empty descriptions on the Channels + # page while the real label/install-hint sat unused in the registry. + plugin_map: dict[str, Any] = {} + try: + # Plugin discovery only runs as a side effect of importing + # model_tools; this server process doesn't do that, so trigger it + # explicitly (idempotent) or plugin_entries() is empty here and + # every plugin platform renders nameless. + from hermes_cli.plugins import discover_plugins + + discover_plugins() + from gateway.platform_registry import platform_registry + + for plugin_entry in platform_registry.plugin_entries(): + plugin_map[plugin_entry.name] = plugin_entry + except Exception: + _log.debug("plugin platform registry unavailable", exc_info=True) + seen: set[str] = set() entries: list[dict[str, Any]] = [] @@ -8420,18 +8487,15 @@ def _messaging_platform_catalog() -> tuple[dict[str, Any], ...]: if member.value in seen: continue seen.add(member.value) - entries.append(_build_catalog_entry(member.value)) + entries.append( + _build_catalog_entry(member.value, plugin_map.get(member.value)) + ) - try: - from gateway.platform_registry import platform_registry - - for plugin_entry in platform_registry.plugin_entries(): - if plugin_entry.name in seen: - continue - seen.add(plugin_entry.name) - entries.append(_build_catalog_entry(plugin_entry.name, plugin_entry)) - except Exception: - _log.debug("plugin platform registry unavailable", exc_info=True) + for name, plugin_entry in plugin_map.items(): + if name in seen: + continue + seen.add(name) + entries.append(_build_catalog_entry(name, plugin_entry)) order = {pid: idx for idx, pid in enumerate(_PLATFORM_ORDER)} entries.sort( diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 895b0ddf7944..070c86943d23 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -3659,6 +3659,42 @@ class TestWebServerEndpoints: finally: platform_registry.unregister("ircfake") + def test_messaging_catalog_prefers_plugin_label_over_enum_pseudo_member(self): + """A plugin platform that leaked into Platform.__members__ as a pseudo- + member must still render with its plugin label, not a title-cased id. + + Regression: Platform("") caches a pseudo-member in the enum; + the catalog iterated the enum FIRST and claimed the id with no plugin + metadata, so bundled plugin platforms (irc, ntfy, photon, …) rendered + as nameless "Irc"/"Ntfy" cards with empty descriptions. + """ + from gateway.config import Platform + from gateway.platform_registry import PlatformEntry, platform_registry + + entry = PlatformEntry( + name="pseudofake", + label="Pseudo Fake (plugin label)", + adapter_factory=lambda cfg: None, + check_fn=lambda: True, + source="plugin", + ) + platform_registry.register(entry) + try: + # Materialize the enum pseudo-member the way any earlier config + # read would (Platform(value) on a registered plugin platform). + member = Platform("pseudofake") + assert member.value == "pseudofake" + assert "PSEUDOFAKE" in Platform.__members__ + + resp = self.client.get("/api/messaging/platforms") + ids = {row["id"]: row for row in resp.json()["platforms"]} + assert "pseudofake" in ids + assert ids["pseudofake"]["name"] == "Pseudo Fake (plugin label)" + finally: + platform_registry.unregister("pseudofake") + Platform._value2member_map_.pop("pseudofake", None) + Platform._member_map_.pop("PSEUDOFAKE", None) + def test_update_messaging_platform_saves_env_and_enablement(self): from hermes_cli.config import load_config, load_env diff --git a/web/src/components/AuthWidget.tsx b/web/src/components/AuthWidget.tsx index 94d1b572c684..93657bf854d2 100644 --- a/web/src/components/AuthWidget.tsx +++ b/web/src/components/AuthWidget.tsx @@ -45,7 +45,14 @@ export function AuthWidget({ className }: AuthWidgetProps) { const [hidden, setHidden] = useState(false); const [error, setError] = useState(null); + // Loopback / --insecure mode: the auth gate is off, so /api/auth/me is a + // guaranteed 401. Don't fire the request at all — it only produces console + // noise ("Failed to load resource: 401") on every dashboard load. + const gated = + typeof window !== "undefined" && !!window.__HERMES_AUTH_REQUIRED__; + useEffect(() => { + if (!gated) return; let cancelled = false; api .getAuthMe() @@ -70,7 +77,10 @@ export function AuthWidget({ className }: AuthWidgetProps) { return () => { cancelled = true; }; - }, []); + }, [gated]); + + // Nothing to show in ungated mode — there is no logged-in identity. + if (!gated) return null; if (hidden) return null; diff --git a/web/src/components/ModelPickerDialog.tsx b/web/src/components/ModelPickerDialog.tsx index e73c959e6f8e..1b280123c19a 100644 --- a/web/src/components/ModelPickerDialog.tsx +++ b/web/src/components/ModelPickerDialog.tsx @@ -217,15 +217,22 @@ export function ModelPickerDialog(props: Props) { // Fuzzy-ranked providers: match on name + slug + the provider's model ids so // typing a model name surfaces its provider (preserves the prior behaviour // where a model match also revealed its provider). - const filteredProviders = useMemo( - () => - fuzzyRank( - providers, - trimmedQuery, - (p) => `${p.name} ${p.slug} ${(p.models ?? []).join(" ")}`, - ).map((r) => r.item), - [providers, trimmedQuery], - ); + // + // With no query, float providers that actually have models to the top + // (stable within each group). A fresh install lists ~40 providers and only + // a couple are configured — burying "OpenRouter · 37 models" under a wall + // of "0 models" rows made the picker feel broken. + const filteredProviders = useMemo(() => { + const ranked = fuzzyRank( + providers, + trimmedQuery, + (p) => `${p.name} ${p.slug} ${(p.models ?? []).join(" ")}`, + ).map((r) => r.item); + if (trimmedQuery) return ranked; + const withModels = ranked.filter((p) => (p.models ?? []).length > 0); + const withoutModels = ranked.filter((p) => (p.models ?? []).length === 0); + return [...withModels, ...withoutModels]; + }, [providers, trimmedQuery]); // A query that matched the SELECTED provider by name/slug (not its models) // located that provider — it shouldn't also hide that provider's models diff --git a/web/src/lib/log-classify.test.ts b/web/src/lib/log-classify.test.ts new file mode 100644 index 000000000000..ea9178390e65 --- /dev/null +++ b/web/src/lib/log-classify.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { classifyLine } from "./log-classify"; + +const TS = "2026-07-26 13:07:45,228"; + +describe("classifyLine", () => { + it("classifies by the structured level token", () => { + expect(classifyLine(`${TS} INFO run_agent: client created`)).toBe("info"); + expect(classifyLine(`${TS} DEBUG registry: scanned 12 tools`)).toBe( + "debug", + ); + expect(classifyLine(`${TS} WARNING hermes_state: WAL disabled`)).toBe( + "warning", + ); + expect(classifyLine(`${TS} ERROR gateway: connect failed`)).toBe("error"); + expect(classifyLine(`${TS} CRITICAL agent: giving up`)).toBe("error"); + }); + + it("does NOT flag INFO lines whose payload mentions errors", () => { + // Regression: these rendered red with the old substring heuristic. + expect( + classifyLine( + `${TS} INFO tui_gateway.ws: ws closed peer=127.0.0.1:43212 parse_errors=0 dispatch_crashes=0 send_failures=1`, + ), + ).toBe("info"); + expect(classifyLine(`${TS} INFO logs: rotating errors.log`)).toBe("info"); + expect( + classifyLine(`${TS} INFO retry: recovered from FatalError subclass`), + ).toBe("info"); + }); + + it("does NOT let payload text spoof a higher level", () => { + expect( + classifyLine(`${TS} INFO chat: user said "ERROR ERROR ERROR"`), + ).toBe("info"); + expect(classifyLine(`${TS} DEBUG probe: WARNING string seen`)).toBe( + "debug", + ); + }); + + it("falls back to word-boundary matching for untimestamped lines", () => { + expect(classifyLine("ValueError: bad input")).toBe("info"); // no bare token + expect(classifyLine("ERROR: something broke")).toBe("error"); + expect(classifyLine("Traceback (most recent call last):")).toBe("error"); + expect(classifyLine(" WARNING low disk")).toBe("warning"); + expect(classifyLine("errors=0 all good")).toBe("info"); + expect(classifyLine("wrote to errors.log")).toBe("info"); + expect(classifyLine("just a plain line")).toBe("info"); + }); +}); diff --git a/web/src/lib/log-classify.ts b/web/src/lib/log-classify.ts new file mode 100644 index 000000000000..51379b16aaab --- /dev/null +++ b/web/src/lib/log-classify.ts @@ -0,0 +1,36 @@ +/** + * Log-line level classification for the dashboard Logs page. + * + * Prefers the structured level token emitted by hermes_logging + * ("2026-07-26 13:07:45,228 INFO …"); falls back to word-boundary matching + * for untimestamped lines (tracebacks, wrapped continuations). Plain + * substring matching is deliberately avoided — INFO lines carrying payloads + * like "parse_errors=0" or paths like "errors.log" must not render red. + */ + +export type LogLevel = "error" | "warning" | "info" | "debug"; + +// Level token as emitted by hermes_logging, anchored to the line head so +// payload text can't spoof the level. +const LEVEL_TOKEN_RE = + /^\d{4}-\d{2}-\d{2}[ T][\d:,.]+\s+(DEBUG|INFO|WARNING|WARN|ERROR|CRITICAL|FATAL)\b/; + +export function classifyLine(line: string): LogLevel { + const token = LEVEL_TOKEN_RE.exec(line)?.[1]; + if (token) { + if (token === "ERROR" || token === "CRITICAL" || token === "FATAL") + return "error"; + if (token === "WARNING" || token === "WARN") return "warning"; + if (token === "DEBUG") return "debug"; + return "info"; + } + const upper = line.toUpperCase(); + if ( + /\b(ERROR|CRITICAL|FATAL)\b/.test(upper) || + upper.startsWith("TRACEBACK (") + ) + return "error"; + if (/\b(WARNING|WARN)\b/.test(upper)) return "warning"; + if (/\bDEBUG\b/.test(upper)) return "debug"; + return "info"; +} diff --git a/web/src/lib/resolve-page-title.test.ts b/web/src/lib/resolve-page-title.test.ts new file mode 100644 index 000000000000..8b2f5694b97b --- /dev/null +++ b/web/src/lib/resolve-page-title.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { resolvePageTitle } from "./resolve-page-title"; +import type { Translations } from "@/i18n/types"; + +// Minimal translations stub — only the fields resolvePageTitle touches. +const t = { + app: { + webUi: "Web UI", + nav: { + analytics: "Analytics", + chat: "Chat", + config: "Config", + cron: "Cron", + documentation: "Documentation", + keys: "Keys", + logs: "Logs", + models: "Models", + profiles: "Profiles", + plugins: "Plugins", + sessions: "Sessions", + skills: "Skills", + }, + }, +} as unknown as Translations; + +describe("resolvePageTitle", () => { + it("uses i18n nav keys for translated routes", () => { + expect(resolvePageTitle("/sessions", t, [])).toBe("Sessions"); + expect(resolvePageTitle("/env", t, [])).toBe("Keys"); + }); + + it("renders initialisms and literal labels correctly", () => { + // Regression: the naive capitalize fallback produced "Mcp". + expect(resolvePageTitle("/mcp", t, [])).toBe("MCP"); + expect(resolvePageTitle("/system", t, [])).toBe("System"); + expect(resolvePageTitle("/channels", t, [])).toBe("Channels"); + expect(resolvePageTitle("/webhooks", t, [])).toBe("Webhooks"); + expect(resolvePageTitle("/pairing", t, [])).toBe("Pairing"); + expect(resolvePageTitle("/files", t, [])).toBe("Files"); + }); + + it("prefers plugin tab labels", () => { + expect( + resolvePageTitle("/kanban", t, [{ path: "/kanban", label: "Kanban" }]), + ).toBe("Kanban"); + }); + + it("falls back to capitalized path segment for unknown routes", () => { + expect(resolvePageTitle("/whatever", t, [])).toBe("Whatever"); + }); + + it("treats root as sessions and trailing slashes as equivalent", () => { + expect(resolvePageTitle("/", t, [])).toBe("Sessions"); + expect(resolvePageTitle("/mcp/", t, [])).toBe("MCP"); + }); +}); diff --git a/web/src/lib/resolve-page-title.ts b/web/src/lib/resolve-page-title.ts index 2b25e1a446ed..15fa5b3824e9 100644 --- a/web/src/lib/resolve-page-title.ts +++ b/web/src/lib/resolve-page-title.ts @@ -15,6 +15,18 @@ const BUILTIN: Record = { "/docs": "documentation", }; +// Built-in routes without an i18n nav key. Keep these in sync with the +// sidebar labels in App.tsx — the naive capitalize fallback below mangles +// initialisms ("/mcp" → "Mcp") and can't match multi-word labels. +const BUILTIN_LITERAL: Record = { + "/files": "Files", + "/mcp": "MCP", + "/channels": "Channels", + "/webhooks": "Webhooks", + "/pairing": "Pairing", + "/system": "System", +}; + export function resolvePageTitle( pathname: string, t: Translations, @@ -32,6 +44,10 @@ export function resolvePageTitle( if (key) { return t.app.nav[key]; } + const literal = BUILTIN_LITERAL[normalized]; + if (literal) { + return literal; + } // Derive title from pathname: "/profiles" → "Profiles" const segment = normalized.slice(1); if (segment) { diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 58806c2733df..81b6b30751df 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -26,7 +26,7 @@ import { Button } from "@nous-research/ui/ui/components/button"; import { Typography } from "@nous-research/ui/ui/components/typography/index"; import { cn } from "@/lib/utils"; import { Copy, PanelRight, RotateCcw, X } from "lucide-react"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useSearchParams } from "react-router-dom"; @@ -407,9 +407,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { return () => mql.removeEventListener("change", onChange); }, []); - useEffect(() => { - // When hidden (non-chat tab) we must not register the header button — - // another page owns the header's end slot at that point. + useLayoutEffect(() => { + // When hidden (non-chat tab) another page owns the header's end slot. + // Don't touch it AT ALL — the persistent chat host mounts (plugin + // manifests resolving) and updates AFTER the routed page's layout + // effect has already filled the slot, so even a "defensive" + // setEnd(null) here wipes that page's header buttons (Cron "Create", + // Profiles "Build", …). Ownership rule: only write to the slot while + // /chat is the active route AND the narrow layout needs the button; + // the effect cleanup handles removal on every transition out. if (!isActive || !narrow) return; setEnd( )} diff --git a/web/src/pages/LogsPage.tsx b/web/src/pages/LogsPage.tsx index 94dd3957b0db..50163cad3137 100644 --- a/web/src/pages/LogsPage.tsx +++ b/web/src/pages/LogsPage.tsx @@ -17,25 +17,16 @@ import { Label } from "@nous-research/ui/ui/components/label"; import { useI18n } from "@/i18n"; import { usePageHeader } from "@/contexts/usePageHeader"; import { PluginSlot } from "@/plugins"; +// Level classification is unit-tested in @/lib/log-classify; it prefers the +// structured level token and falls back to word-boundary matching so payload +// text like "parse_errors=0" can't render an INFO line red. +import { classifyLine } from "@/lib/log-classify"; const FILES = ["agent", "errors", "gateway"] as const; const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const; const COMPONENTS = ["all", "gateway", "agent", "tools", "cli", "cron"] as const; const LINE_COUNTS = [50, 100, 200, 500] as const; -function classifyLine(line: string): "error" | "warning" | "info" | "debug" { - const upper = line.toUpperCase(); - if ( - upper.includes("ERROR") || - upper.includes("CRITICAL") || - upper.includes("FATAL") - ) - return "error"; - if (upper.includes("WARNING") || upper.includes("WARN")) return "warning"; - if (upper.includes("DEBUG")) return "debug"; - return "info"; -} - const LINE_COLORS: Record = { error: "text-destructive", warning: "text-warning",