mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-09-03 15:16:26 +00:00
Split the web UI into a page per section
The dashboard was a single route that swapped section components via a `section` state field. Convert it to the App Router's multi-page model so each left-nav selection is its own route (/, /repositories, /agents, /schedules, /approvals, /servers, /activity, /shared, /login), making the pages modular and independently updatable. - Move the token gate + store provider + sidebar into a persistent frame (AppFrame + Shell) rendered by the root layout, so auth, the polling loop, and shared state survive client-side navigation. - Sidebar items are now <Link> routes; the active item and the store's polled section are derived from the URL (lib/nav). - Each section gets an app/<section>/page.tsx; Runs stays at root and keeps its full-height split layout, the rest render in the shared scroll frame. - Emit per-route index.html (trailingSlash) so the FastAPI StaticFiles mount serves clean slash-terminated URLs with no SPA rewrite. - Regenerate the bundled static export. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PsAeGVadULRzPhV2PRDttM
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
/* Auth frame: token gate → shell. Lives in the root layout so it wraps every page and
|
||||
* persists across client-side navigation. Client-only; the exported HTML is a shell and
|
||||
* every byte of data is fetched by the browser from the authed API after the token is
|
||||
* supplied. A 401 from any call clears the token and re-prompts with an error. */
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { DashboardProvider } from "@/components/store";
|
||||
import { Shell } from "@/components/Shell";
|
||||
import { TokenGate } from "@/components/TokenGate";
|
||||
import { sectionFromPath } from "@/lib/nav";
|
||||
|
||||
const TOKEN_KEY = "handler_token";
|
||||
|
||||
export function AppFrame({ children }: { children: React.ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const pathname = usePathname();
|
||||
|
||||
// Read the stored token after mount (localStorage is client-only).
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem(TOKEN_KEY);
|
||||
if (stored) setToken(stored);
|
||||
}, []);
|
||||
|
||||
const saveToken = useCallback((t: string) => {
|
||||
window.localStorage.setItem(TOKEN_KEY, t);
|
||||
setError("");
|
||||
setToken(t);
|
||||
}, []);
|
||||
|
||||
const signOut = useCallback(() => {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
setToken(null);
|
||||
}, []);
|
||||
|
||||
const onUnauthorized = useCallback(() => {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
setToken(null);
|
||||
setError("Invalid token — please try again.");
|
||||
}, []);
|
||||
|
||||
if (!token) {
|
||||
return <TokenGate error={error} onSubmit={saveToken} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardProvider
|
||||
token={token}
|
||||
onUnauthorized={onUnauthorized}
|
||||
initialSection={sectionFromPath(pathname)}
|
||||
>
|
||||
<Shell onSignOut={signOut}>{children}</Shell>
|
||||
</DashboardProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
/* The Control Center shell: a left nav (Runs / Repositories / Agents / Approvals / Git
|
||||
* Servers / Activity / Shared) and the active section on the right, matching the design's
|
||||
* hub layout. Command feedback and load errors surface as banners at the top of main. */
|
||||
"use client";
|
||||
|
||||
import { useDashboard, type Section } from "@/components/store";
|
||||
import { RunsSection } from "@/components/sections/RunsSection";
|
||||
import { RepositoriesSection } from "@/components/sections/RepositoriesSection";
|
||||
import { AgentsSection } from "@/components/sections/AgentsSection";
|
||||
import { SchedulesSection } from "@/components/sections/SchedulesSection";
|
||||
import { ApprovalsSection } from "@/components/sections/ApprovalsSection";
|
||||
import { GitServersSection } from "@/components/sections/GitServersSection";
|
||||
import { ActivitySection } from "@/components/sections/ActivitySection";
|
||||
import { SharedSection } from "@/components/sections/SharedSection";
|
||||
import { LoginSection } from "@/components/sections/LoginSection";
|
||||
|
||||
interface NavDef {
|
||||
key: Section;
|
||||
label: string;
|
||||
count: (s: ReturnType<typeof useDashboard>) => number;
|
||||
accent?: (s: ReturnType<typeof useDashboard>) => boolean;
|
||||
}
|
||||
|
||||
const NAV: NavDef[] = [
|
||||
{
|
||||
key: "runs",
|
||||
label: "Runs",
|
||||
count: (s) => s.agents.length,
|
||||
accent: (s) => s.agents.some((a) => a.status === "paused_for_input"),
|
||||
},
|
||||
{ key: "repositories", label: "Repositories", count: (s) => s.projects.length },
|
||||
{ key: "agents", label: "Agents", count: (s) => s.agents.length },
|
||||
{ key: "schedules", label: "Schedules", count: (s) => s.schedules.length },
|
||||
{ key: "approvals", label: "Approvals", count: (s) => s.approvals.length },
|
||||
{ key: "servers", label: "Git Servers", count: (s) => s.hosts.length },
|
||||
{ key: "activity", label: "Activity", count: (s) => s.commands.length },
|
||||
{ key: "shared", label: "Shared", count: (s) => s.shared.context.length },
|
||||
{
|
||||
key: "login",
|
||||
label: "Claude Login",
|
||||
count: () => 0,
|
||||
// Draw the eye to it until Claude is logged in on the host this session.
|
||||
accent: (s) => s.claudeLogin.status !== "done",
|
||||
},
|
||||
];
|
||||
|
||||
export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
|
||||
const s = useDashboard();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<aside className="sidebar">
|
||||
<div className="brand">
|
||||
<span className="logo" />
|
||||
Claude Monitor
|
||||
</div>
|
||||
{NAV.map((n) => {
|
||||
const c = n.count(s);
|
||||
const isAccent = n.accent?.(s) ?? false;
|
||||
return (
|
||||
<button
|
||||
key={n.key}
|
||||
className={`nav-item${s.section === n.key ? " active" : ""}`}
|
||||
onClick={() => s.setSection(n.key)}
|
||||
>
|
||||
<span>{n.label}</span>
|
||||
<span className="count" style={isAccent ? { color: "var(--lw-warning-fg)" } : undefined}>
|
||||
{c || ""}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
)}
|
||||
|
||||
{s.section === "runs" ? (
|
||||
<RunsSection />
|
||||
) : (
|
||||
<div className="main-scroll">
|
||||
{s.section === "repositories" && <RepositoriesSection />}
|
||||
{s.section === "agents" && <AgentsSection />}
|
||||
{s.section === "schedules" && <SchedulesSection />}
|
||||
{s.section === "approvals" && <ApprovalsSection />}
|
||||
{s.section === "servers" && <GitServersSection />}
|
||||
{s.section === "activity" && <ActivitySection />}
|
||||
{s.section === "shared" && <SharedSection />}
|
||||
{s.section === "login" && <LoginSection />}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/* The Control Center shell: a left nav (Runs / Repositories / Agents / Approvals / Git
|
||||
* Servers / Activity / Shared / Claude Login) 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 },
|
||||
login: {
|
||||
count: () => 0,
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -189,17 +189,22 @@ export function useDashboard(): StoreValue {
|
||||
export function DashboardProvider({
|
||||
token,
|
||||
onUnauthorized,
|
||||
initialSection = "runs",
|
||||
children,
|
||||
}: {
|
||||
token: string;
|
||||
onUnauthorized: () => void;
|
||||
/* Which section is on screen at mount, derived from the URL by the auth frame. The
|
||||
* provider lives in the root layout and mounts once, so this only seeds the first
|
||||
* poll; later navigation updates the section through setSection. */
|
||||
initialSection?: Section;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const client = useMemo(() => createClient(token, onUnauthorized), [token, onUnauthorized]);
|
||||
const clientRef = useRef(client);
|
||||
clientRef.current = client;
|
||||
|
||||
const [section, setSectionRaw] = useState<Section>("runs");
|
||||
const [section, setSectionRaw] = useState<Section>(initialSection);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [agents, setAgents] = useState<RunAgent[]>([]);
|
||||
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
|
||||
|
||||
Reference in New Issue
Block a user