mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-27 17:58:07 +00:00
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:
parent
8c3c52b008
commit
6d1e08b2bc
11 changed files with 325 additions and 41 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
50
web/src/lib/log-classify.test.ts
Normal file
50
web/src/lib/log-classify.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
36
web/src/lib/log-classify.ts
Normal file
36
web/src/lib/log-classify.ts
Normal 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";
|
||||
}
|
||||
56
web/src/lib/resolve-page-title.test.ts
Normal file
56
web/src/lib/resolve-page-title.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue