Files
handler/frontend/components/Shell.tsx
T
Claude 07d8c3aa19 Turn the Claude Login page into a full Claude management page
The dashboard's Claude page now manages the whole Claude Code install agents
run on, not just the account login:

- Skills: operator-authored SKILL.md rows, synced to each worker's user-level
  ~/.claude/skills at every launch. Managed dirs carry a .handler-managed
  marker so deletions in the UI propagate while hand-installed skills survive.
- Connectors: MCP servers (stdio/http/sse) written per-launch as
  .claude/mcp-servers.json and passed to claude via --mcp-config, so nothing
  lands in the managed repo's tracked tree.
- Plugins: marketplace-pinned plugins folded into generated settings as
  extraKnownMarketplaces + enabledPlugins, installing on boot of headless runs.
- Permissions: defaultMode override plus allow/deny/ask rules merged over the
  env baseline into every generated settings.json.

All of it is plain DB state (new claude_skills / claude_connectors /
claude_plugins / claude_config tables, migration 0010) edited through the new
admin-gated /claude/* API routes and applied by the control container at spawn
and resume — changes reach the next launch of every agent with no redeploy.

The login flow moved into the page's Account tab unchanged; /login redirects
to /claude for old bookmarks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f42XmjtVsc3zQ9Dhn6DqZ
2026-07-23 13:32:41 +00:00

103 lines
3.8 KiB
TypeScript

/* The Control Center shell: a left nav (Runs / Repositories / Agents / Approvals / Git
* Servers / Activity / Shared / Claude) and the active route's page on the right,
* matching the design's hub layout. Each nav item is a real route, so pages are modular
* and independently loadable; this shell lives in the root layout and persists across
* navigation, keeping the store, polling loop, and auth alive between pages. Command
* feedback and load errors surface as banners at the top of main. */
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { useDashboard } from "@/components/store";
import { NAV_ROUTES, sectionFromPath } from "@/lib/nav";
import type { Section } from "@/components/store";
type Store = ReturnType<typeof useDashboard>;
/* Per-section nav badge: the count on the right and whether it should draw the eye. Keyed
* by section so the route table in lib/nav stays free of store-shaped logic. */
const BADGES: Partial<Record<Section, { count: (s: Store) => number; accent?: (s: Store) => boolean }>> = {
runs: {
count: (s) => s.agents.length,
accent: (s) => s.agents.some((a) => a.status === "paused_for_input"),
},
repositories: { count: (s) => s.projects.length },
agents: { count: (s) => s.agents.length },
schedules: { count: (s) => s.schedules.length },
approvals: { count: (s) => s.approvals.length },
servers: { count: (s) => s.hosts.length },
activity: { count: (s) => s.commands.length },
shared: { count: (s) => s.shared.context.length },
claude: {
// Everything managed on the page: skills + connectors + plugins.
count: (s) => s.claudeSkills.length + s.claudeConnectors.length + s.claudePlugins.length,
// Draw the eye to it until Claude is logged in on the host this session.
accent: (s) => s.claudeLogin.status !== "done",
},
};
export function Shell({ onSignOut, children }: { onSignOut: () => void; children: React.ReactNode }) {
const s = useDashboard();
const pathname = usePathname();
const active = sectionFromPath(pathname);
// Tell the store which section is on screen so its polling loop fetches the right data.
const { setSection } = s;
useEffect(() => {
setSection(active);
}, [active, setSection]);
return (
<div className="app">
<aside className="sidebar">
<div className="brand">
<span className="logo" />
Claude Monitor
</div>
{NAV_ROUTES.map((n) => {
const badge = BADGES[n.key];
const c = badge?.count(s) ?? 0;
const isAccent = badge?.accent?.(s) ?? false;
return (
<Link
key={n.key}
href={n.href}
className={`nav-item${active === n.key ? " active" : ""}`}
>
<span>{n.label}</span>
<span className="count" style={isAccent ? { color: "var(--lw-warning-fg)" } : undefined}>
{c || ""}
</span>
</Link>
);
})}
<div className="sidebar-spacer" />
<div className="sidebar-foot">
<button className="nav-item" onClick={s.refresh} title="Refresh now">
<span>Refresh</span>
<span className="count"></span>
</button>
<button className="nav-item" onClick={onSignOut} title="Sign out / change token">
<span>Sign out</span>
</button>
</div>
</aside>
<main className="main">
{s.cmd.text && (
<p className={`banner ${s.cmd.error ? "err" : "ok"}`} style={{ marginTop: 16 }}>
{s.cmd.text}
</p>
)}
{s.lastError && (
<p className="banner err" style={{ marginTop: 12 }}>
{s.lastError}
</p>
)}
{children}
</main>
</div>
);
}