fix(dashboard): QA pass — log colors, nameless channels, config bool, UX gaps

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.
This commit is contained in:
teknium1 2026-07-26 14:18:21 -07:00 committed by Teknium
parent 8c3c52b008
commit 6d1e08b2bc
11 changed files with 325 additions and 41 deletions

View file

@ -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("<plugin id>")`` — 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(

View file

@ -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("<plugin id>") 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

View file

@ -45,7 +45,14 @@ export function AuthWidget({ className }: AuthWidgetProps) {
const [hidden, setHidden] = useState(false);
const [error, setError] = useState<string | null>(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;

View file

@ -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

View file

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

View file

@ -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";
}

View file

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

View file

@ -15,6 +15,18 @@ const BUILTIN: Record<string, keyof Translations["app"]["nav"]> = {
"/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<string, string> = {
"/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) {

View file

@ -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(
<Button

View file

@ -978,8 +978,20 @@ export default function CronPage() {
{jobs.length === 0 && (
<Card>
<CardContent className="py-8 text-center text-sm text-muted-foreground">
{t.cron.noJobs}
<CardContent className="flex flex-col items-center gap-3 py-8 text-center text-sm text-muted-foreground">
<span>{t.cron.noJobs}</span>
<Button
className="uppercase"
size="sm"
onClick={() => {
setCreateProfile(
selectedProfile === "all" ? "default" : selectedProfile,
);
setCreateModalOpen(true);
}}
>
{t.common.create}
</Button>
</CardContent>
</Card>
)}

View file

@ -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<string, string> = {
error: "text-destructive",
warning: "text-warning",