diff --git a/apps/desktop/src/app/command-center/index.tsx b/apps/desktop/src/app/command-center/index.tsx index f6f2ed0324a..7659518092f 100644 --- a/apps/desktop/src/app/command-center/index.tsx +++ b/apps/desktop/src/app/command-center/index.tsx @@ -17,7 +17,8 @@ import { BookmarkFilled, Download, MessageCircle, - Trash2 + Trash2, + Wrench } from '@/lib/icons' import { exportSession } from '@/lib/session-export' import { cn } from '@/lib/utils' @@ -30,9 +31,14 @@ import { useRouteEnumParam } from '../hooks/use-route-enum-param' import { OverlayMain, OverlayNavItem, OverlaySidebar, OverlaySplitLayout } from '../overlays/overlay-split-layout' import { OverlayView } from '../overlays/overlay-view' -export type CommandCenterSection = 'sessions' | 'system' | 'usage' +import { MaintenancePanel } from './maintenance' -const SECTIONS = ['sessions', 'system', 'usage'] as const satisfies readonly CommandCenterSection[] +export type CommandCenterSection = 'maintenance' | 'sessions' | 'system' | 'usage' + +const SECTIONS = ['sessions', 'system', 'usage', 'maintenance'] as const satisfies readonly CommandCenterSection[] + +const LOG_FILES = ['agent', 'errors', 'gateway', 'desktop'] as const +const LOG_LEVELS = ['ALL', 'INFO', 'WARNING', 'ERROR'] as const const USAGE_PERIODS = [7, 30, 90] as const type UsagePeriod = (typeof USAGE_PERIODS)[number] @@ -125,6 +131,9 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on const [query, setQuery] = useState('') const [status, setStatus] = useState(null) const [logs, setLogs] = useState([]) + const [logFile, setLogFile] = useState<(typeof LOG_FILES)[number]>('agent') + const [logLevel, setLogLevel] = useState<(typeof LOG_LEVELS)[number]>('ALL') + const [logQuery, setLogQuery] = useState('') const [systemLoading, setSystemLoading] = useState(false) const [systemError, setSystemError] = useState('') const [systemAction, setSystemAction] = useState(null) @@ -165,8 +174,9 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on const [nextStatus, nextLogs] = await Promise.all([ getStatus(), getLogs({ - file: 'agent', - lines: 120 + file: logFile, + level: logLevel, + lines: 200 }) ]) @@ -177,7 +187,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on } finally { setSystemLoading(false) } - }, []) + }, [logFile, logLevel]) const refreshUsage = useCallback(async (days: UsagePeriod) => { const requestId = usageRequestRef.current + 1 @@ -203,10 +213,12 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on }, []) useEffect(() => { - if (section === 'system' && !status && !systemLoading) { + // Refetch when the panel opens and whenever the log file/level filters + // change (refreshSystem's identity tracks them). + if (section === 'system') { void refreshSystem() } - }, [refreshSystem, section, status, systemLoading]) + }, [refreshSystem, section]) useEffect(() => { if (section === 'usage') { @@ -224,6 +236,17 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on const sessionListHasResults = filteredSessions.length > 0 + // Client-side substring filter over the fetched tail (matches `hermes logs --search`). + const visibleLogs = useMemo(() => { + const needle = logQuery.trim().toLowerCase() + + if (!needle) { + return logs + } + + return logs.filter(line => line.toLowerCase().includes(needle)) + }, [logQuery, logs]) + const runSystemAction = useCallback( async (kind: 'restart' | 'update') => { setSystemError('') @@ -272,7 +295,15 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on {SECTIONS.map(value => ( setSection(value)} @@ -368,6 +399,8 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on period={usagePeriod} usage={usage} /> + ) : section === 'maintenance' ? ( + ) : (
@@ -416,10 +449,31 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
-
+
{cc.recentLogs} +
+ setLogFile(id)} + options={LOG_FILES.map(value => ({ id: value, label: value }))} + value={logFile} + /> + setLogLevel(id)} + options={LOG_LEVELS.map(value => ({ + id: value, + label: value === 'ALL' ? 'all' : value.toLowerCase() + }))} + value={logLevel} + /> + setLogQuery(next)} + placeholder={cc.logSearchPlaceholder} + value={logQuery} + /> +
{systemError && ( @@ -431,7 +485,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on className="min-h-0 flex-1 overflow-auto whitespace-pre-wrap wrap-break-word rounded-lg border border-(--ui-stroke-tertiary) bg-(--ui-bg-quinary) p-3 font-mono text-[0.65rem] leading-relaxed text-(--ui-text-tertiary)" data-selectable-text="true" > - {logs.length ? logs.join('\n') : cc.noLogs} + {visibleLogs.length ? visibleLogs.join('\n') : cc.noLogs}
diff --git a/apps/desktop/src/app/command-center/maintenance.tsx b/apps/desktop/src/app/command-center/maintenance.tsx new file mode 100644 index 00000000000..e8ee2f0e4e6 --- /dev/null +++ b/apps/desktop/src/app/command-center/maintenance.tsx @@ -0,0 +1,416 @@ +import { useCallback, useEffect, useState } from 'react' + +import { PageLoader } from '@/components/page-loader' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + type ActionResponse, + type CuratorStatusResponse, + type DebugShareResponse, + getActionStatus, + getCuratorStatus, + getMemoryStatus, + type MemoryStatusResponse, + resetMemory, + runBackup, + runCurator, + runDebugShare, + runDoctor, + runSecurityAudit, + setCuratorPaused +} from '@/hermes' +import { useI18n } from '@/i18n' +import { AlertCircle } from '@/lib/icons' +import { cn } from '@/lib/utils' +import { upsertDesktopActionTask } from '@/store/activity' +import { notify, notifyError } from '@/store/notifications' +import type { ActionStatusResponse } from '@/types/hermes' + +const ACTION_POLL_MS = 1200 +const ACTION_POLL_LIMIT = 240 // ~5 minutes of polling before giving up. + +function formatBytes(size: number): string { + if (size <= 0) { + return '' + } + + if (size >= 1024 * 1024) { + return `${(size / (1024 * 1024)).toFixed(1)} MB` + } + + if (size >= 1024) { + return `${(size / 1024).toFixed(1)} KB` + } + + return `${size} B` +} + +/** Maintenance panel — desktop parity for `hermes doctor` / `security audit` / + * `backup` / `debug share` / `curator` / `memory` (the dashboard System page's + * ops section). Spawn-based actions tail their logs inline via the shared + * /api/actions status endpoint. */ +export function MaintenancePanel() { + const { t } = useI18n() + const mm = t.commandCenter.maintenance + + const [actionName, setActionName] = useState(null) + const [actionStatus, setActionStatus] = useState(null) + const [curator, setCurator] = useState(null) + const [curatorBusy, setCuratorBusy] = useState(false) + const [memory, setMemory] = useState(null) + const [memoryBusy, setMemoryBusy] = useState(false) + const [share, setShare] = useState(null) + const [sharing, setSharing] = useState(false) + const [error, setError] = useState('') + + useEffect(() => { + let cancelled = false + + getCuratorStatus() + .then(next => !cancelled && setCurator(next)) + .catch(() => {}) + getMemoryStatus() + .then(next => !cancelled && setMemory(next)) + .catch(() => {}) + + return () => void (cancelled = true) + }, []) + + // Tail the most recently launched spawn action. + useEffect(() => { + if (!actionName) { + return + } + + let cancelled = false + let polls = 0 + let timer: null | number = null + + const poll = async () => { + try { + const status = await getActionStatus(actionName, 200) + + if (cancelled) { + return + } + + setActionStatus(status) + upsertDesktopActionTask(status) + polls += 1 + + if (status.running && polls < ACTION_POLL_LIMIT) { + timer = window.setTimeout(() => void poll(), ACTION_POLL_MS) + } + } catch { + // Status endpoint hiccup — stop tailing; the activity rail still has the task. + } + } + + void poll() + + return () => { + cancelled = true + + if (timer !== null) { + window.clearTimeout(timer) + } + } + }, [actionName]) + + const launch = useCallback( + async (label: string, start: () => Promise) => { + setError('') + + try { + const started = await start() + setActionStatus(null) + setActionName(started.name) + notify({ kind: 'success', title: mm.actionStarted(label), message: '' }) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + notifyError(err, mm.actionFailed(label)) + } + }, + [mm] + ) + + const shareDebug = useCallback(async () => { + setSharing(true) + setShare(null) + setError('') + + try { + setShare(await runDebugShare()) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + notifyError(err, mm.debugShareFailed) + } finally { + setSharing(false) + } + }, [mm]) + + const toggleCurator = useCallback(async () => { + if (!curator) { + return + } + + setCuratorBusy(true) + + try { + const next = !curator.paused + await setCuratorPaused(next) + setCurator({ ...curator, paused: next }) + } catch (err) { + notifyError(err, mm.actionFailed(mm.curator)) + } finally { + setCuratorBusy(false) + } + }, [curator, mm]) + + const doResetMemory = useCallback( + async (target: 'all' | 'memory' | 'user', label: string) => { + if (!window.confirm(mm.resetConfirm(label))) { + return + } + + setMemoryBusy(true) + + try { + const result = await resetMemory(target) + notify({ kind: 'success', title: mm.resetDone(result.deleted.join(', ') || label), message: '' }) + setMemory(await getMemoryStatus()) + } catch (err) { + notifyError(err, mm.resetFailed) + } finally { + setMemoryBusy(false) + } + }, + [mm] + ) + + return ( +
+ {error && ( + + + {error} + + )} + +
+ {mm.runOps} + void launch(mm.doctor, runDoctor)} + /> + void launch(mm.securityAudit, runSecurityAudit)} + /> + void launch(mm.backup, runBackup)} + /> + void shareDebug()} + /> + + {share && Object.keys(share.urls).length > 0 && ( +
+
+ {mm.debugShareLinks} +
+ {Object.entries(share.urls).map(([key, url]) => ( +
+ + {key}: {url} + + +
+ ))} +
+ )} + + {actionStatus && ( +
+
+ {mm.viewLog} + {actionStatus.running && {mm.running}} +
+
+              {actionStatus.lines.join('\n')}
+            
+
+ )} +
+ +
+ {mm.curator} + {!curator ? ( + + ) : ( +
+
+
+ {mm.curator} + + {!curator.enabled ? mm.curatorDisabled : curator.paused ? mm.curatorPaused : mm.curatorActive} + +
+
+ {mm.curatorDesc} + {' · '} + {curator.last_run_at ? mm.curatorLastRun(curator.last_run_at) : mm.curatorNeverRan} +
+
+
+ {curator.enabled && ( + + )} + +
+
+ )} +
+ +
+ {mm.memoryData} + {!memory ? ( + + ) : ( +
+
+ {mm.memoryDataDesc} + {' · '} + {mm.memoryProvider(memory.active || mm.builtinMemory)} +
+ void doResetMemory('memory', mm.memoryFile)} + resetLabel={mm.resetMemory} + size={memory.builtin_files.memory} + sizeLabel={memory.builtin_files.memory > 0 ? formatBytes(memory.builtin_files.memory) : mm.empty} + /> + void doResetMemory('user', mm.userFile)} + resetLabel={mm.resetUser} + size={memory.builtin_files.user} + sizeLabel={memory.builtin_files.user > 0 ? formatBytes(memory.builtin_files.user) : mm.empty} + /> +
+ )} +
+
+ ) +} + +function SectionLabel({ children }: { children: string }) { + return ( +
+ {children} +
+ ) +} + +function OpRow({ + description, + disabled, + label, + onRun +}: { + description: string + disabled?: boolean + label: string + onRun: () => void +}) { + return ( +
+
+
{label}
+
+ {description} +
+
+ +
+ ) +} + +function MemoryFileRow({ + busy, + label, + onReset, + resetLabel, + size, + sizeLabel +}: { + busy: boolean + label: string + onReset: () => void + resetLabel: string + size: number + sizeLabel: string +}) { + return ( +
+
+ {label} + + {sizeLabel} + +
+ +
+ ) +} diff --git a/apps/desktop/src/app/settings/mcp-settings.tsx b/apps/desktop/src/app/settings/mcp-settings.tsx index b2e7f828943..c638f0b2cd1 100644 --- a/apps/desktop/src/app/settings/mcp-settings.tsx +++ b/apps/desktop/src/app/settings/mcp-settings.tsx @@ -1,16 +1,27 @@ import { useStore } from '@nanostores/react' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' +import { Switch } from '@/components/ui/switch' import { Textarea } from '@/components/ui/textarea' -import { getHermesConfigRecord, type HermesGateway, saveHermesConfig } from '@/hermes' +import { + getHermesConfigRecord, + getMcpCatalog, + type HermesGateway, + installMcpCatalogEntry, + type McpCatalogEntry, + saveHermesConfig, + setMcpServerEnabled, + testMcpServer +} from '@/hermes' import { useI18n } from '@/i18n' import { Wrench } from '@/lib/icons' import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import { $activeSessionId } from '@/store/session' -import type { HermesConfigRecord } from '@/types/hermes' +import type { HermesConfigRecord, McpServerTestResponse } from '@/types/hermes' import { EmptyState, LoadingState, Pill, SettingsContent } from './primitives' import { useDeepLinkHighlight } from './use-deep-link-highlight' @@ -21,6 +32,7 @@ interface McpSettingsProps { } type McpServers = Record> +type McpView = 'catalog' | 'servers' const EMPTY_SERVER = { command: '', @@ -47,12 +59,16 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) { const { t } = useI18n() const m = t.settings.mcp const activeSessionId = useStore($activeSessionId) + const [view, setView] = useState('servers') const [config, setConfig] = useState(null) const [selected, setSelected] = useState(null) const [name, setName] = useState('') const [body, setBody] = useState('') const [saving, setSaving] = useState(false) const [reloading, setReloading] = useState(false) + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState(null) + const [togglingEnabled, setTogglingEnabled] = useState(false) useEffect(() => { let cancelled = false @@ -89,8 +105,18 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) { setName(selected ?? '') setBody(JSON.stringify(server ?? EMPTY_SERVER, null, 2)) + setTestResult(null) }, [selected, servers]) + const refreshConfig = useCallback(async () => { + try { + const next = await getHermesConfigRecord() + setConfig(next) + } catch (err) { + notifyError(err, m.failedLoad) + } + }, [m.failedLoad]) + if (!config) { return } @@ -185,88 +211,324 @@ export function McpSettings({ gateway, onConfigSaved }: McpSettingsProps) { } } + const runTest = async (serverName: string) => { + setTesting(true) + setTestResult(null) + + try { + const result = await testMcpServer(serverName) + setTestResult(result) + } catch (err) { + setTestResult({ ok: false, error: err instanceof Error ? err.message : String(err), tools: [] }) + } finally { + setTesting(false) + } + } + + const toggleEnabled = async (serverName: string, enabled: boolean) => { + setTogglingEnabled(true) + + try { + await setMcpServerEnabled(serverName, enabled) + // Mirror the change locally so the editor and list stay in sync. + const nextServers = { ...servers, [serverName]: { ...servers[serverName], enabled } } + setConfig({ ...config, mcp_servers: nextServers }) + notify({ + kind: 'success', + title: enabled ? m.serverEnabled(serverName) : m.serverDisabled(serverName), + message: '' + }) + } catch (err) { + notifyError(err, m.toggleFailed(serverName)) + } finally { + setTogglingEnabled(false) + } + } + + const selectedEnabled = selected ? servers[selected]?.enabled !== false : true + return ( -
- - -
- -
-
- {names.length === 0 ? ( - - ) : ( -
- {names.map(serverName => { - const server = servers[serverName] - const active = selected === serverName - - return ( - - ) - })} -
- )} +
+
+ setView('servers')} /> + setView('catalog')} />
- -
-
- - {selected ? m.editServer : m.newServer} -
- -