feat: container monitoring frontend UI (PR-2)
- Custom ToastContext + ToastProvider + ToastContainer (~170 lines, no deps) - useEvents() SSE hook with exponential backoff reconnect - EventProvider context for app-wide SSE stream sharing - Event-to-toast bridge with severity mapping and deduplication - Real-time status badge updates replacing 30s polling - EventSource auth probe (401/429 detection via fetch) - 14 frontend tests (useEvents + toast-rules) Quality gates: vitest 14 passed, tsc clean, eslint clean
This commit is contained in:
@@ -7,135 +7,164 @@ import { useTheme } from "../hooks/use-theme";
|
||||
import { useAuth } from "../state/auth";
|
||||
import { useSessions } from "../state/sessions";
|
||||
import { useMobileViewport } from "../hooks/use-mobile-viewport";
|
||||
import { EventProvider } from "../state/events";
|
||||
import { ToastProvider } from "../state/toast";
|
||||
import { EventToastBridge } from "./event-toast-bridge";
|
||||
import { Icon } from "./icon";
|
||||
import { MobileNav } from "./mobile-nav";
|
||||
import type { IconName } from "../utils/icons";
|
||||
|
||||
const NAV_ITEMS: { to: string; label: string; icon: IconName; badge?: "sessions" }[] = [
|
||||
{ to: "/", label: "Home", icon: "dashboard" },
|
||||
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
|
||||
{ to: "/settings", label: "Settings", icon: "settings" }
|
||||
const NAV_ITEMS: {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: IconName;
|
||||
badge?: "sessions";
|
||||
}[] = [
|
||||
{ to: "/", label: "Home", icon: "dashboard" },
|
||||
{ to: "/sessions", label: "Sessions", icon: "terminal", badge: "sessions" },
|
||||
{ to: "/projects", label: "Projects", icon: "projects" },
|
||||
{ to: "/tool-workshop", label: "Tool Workshop", icon: "settings" },
|
||||
{ to: "/config-profiles", label: "Config Profiles", icon: "folder" },
|
||||
{ to: "/settings", label: "Settings", icon: "settings" },
|
||||
];
|
||||
|
||||
const SessionItem = ({ session }: { session: Session }) => {
|
||||
const isRunning = session.status === "running";
|
||||
const isRunning = session.status === "running";
|
||||
|
||||
return (
|
||||
<a
|
||||
href={session.url ?? `/projects/${session.project_id}`}
|
||||
target={session.url ? "_blank" : undefined}
|
||||
rel={session.url ? "noopener noreferrer" : undefined}
|
||||
className="nav-item session-item"
|
||||
title={`${session.display_name} (${session.status})`}
|
||||
>
|
||||
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||
<span className="session-name">{session.display_name}</span>
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<a
|
||||
href={session.url ?? `/projects/${session.project_id}`}
|
||||
target={session.url ? "_blank" : undefined}
|
||||
rel={session.url ? "noopener noreferrer" : undefined}
|
||||
className="nav-item session-item"
|
||||
title={`${session.display_name} (${session.status})`}
|
||||
>
|
||||
<span className={`session-status ${isRunning ? "running" : ""}`} />
|
||||
<Icon name={session.tool_icon as IconName} size="sm" />
|
||||
<span className="session-name">{session.display_name}</span>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppShell = () => {
|
||||
useTheme();
|
||||
const { user, logout } = useAuth();
|
||||
const { sessions, setAllSessions } = useSessions();
|
||||
const location = useLocation();
|
||||
const isMobile = useMobileViewport();
|
||||
const isMobileTerminal = isMobile && location.pathname.includes("/instances/") && location.pathname.includes("/terminal");
|
||||
useTheme();
|
||||
const { user, logout } = useAuth();
|
||||
const { sessions, setAllSessions } = useSessions();
|
||||
const location = useLocation();
|
||||
const isMobile = useMobileViewport();
|
||||
const isMobileTerminal =
|
||||
isMobile &&
|
||||
location.pathname.includes("/instances/") &&
|
||||
location.pathname.includes("/terminal");
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserSessions();
|
||||
setAllSessions(data);
|
||||
} catch {
|
||||
// Silently fail - sessions are optional
|
||||
}
|
||||
}, [setAllSessions]);
|
||||
const loadSessions = useCallback(async () => {
|
||||
try {
|
||||
const data = await getUserSessions();
|
||||
setAllSessions(data);
|
||||
} catch {
|
||||
// Silently fail - sessions are optional
|
||||
}
|
||||
}, [setAllSessions]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
// Poll every 30 seconds (reduced from 10s to avoid ERR_NETWORK_CHANGED from Docker network changes)
|
||||
const interval = setInterval(() => {
|
||||
void loadSessions();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
useEffect(() => {
|
||||
void loadSessions();
|
||||
// Poll every 30 seconds (reduced from 10s to avoid ERR_NETWORK_CHANGED from Docker network changes)
|
||||
const interval = setInterval(() => {
|
||||
void loadSessions();
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadSessions]);
|
||||
|
||||
if (isMobileTerminal) {
|
||||
return (
|
||||
<div className="shell mobile-terminal-shell">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isMobileTerminal) {
|
||||
return (
|
||||
<EventProvider>
|
||||
<ToastProvider>
|
||||
<EventToastBridge />
|
||||
<div className="shell mobile-terminal-shell">
|
||||
<Outlet />
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</EventProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
<Link className="brand" to="/">
|
||||
Headquarter
|
||||
</Link>
|
||||
<div className="header-actions">
|
||||
<Link className="user-chip" to="/profile">
|
||||
{user?.name ?? "User"}
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="logout" size="sm" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
return (
|
||||
<EventProvider>
|
||||
<ToastProvider>
|
||||
<EventToastBridge />
|
||||
<div className="shell">
|
||||
<header className="shell-header">
|
||||
<Link className="brand" to="/">
|
||||
Headquarter
|
||||
</Link>
|
||||
<div className="header-actions">
|
||||
<Link className="user-chip" to="/profile">
|
||||
{user?.name ?? "User"}
|
||||
</Link>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Icon name="logout" size="sm" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="shell-body">
|
||||
{!isMobile && (
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const activeCount = sessions.filter((s) => s.status === "running").length;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) => (isActive ? "nav-item nav-item-active" : "nav-item")}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{item.badge === "sessions" && activeCount > 0 && (
|
||||
<span className="nav-badge">{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<div className="nav-divider" />
|
||||
<div className="nav-section-title">Live sessions</div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
<div className="shell-body">
|
||||
{!isMobile && (
|
||||
<aside className="shell-nav" aria-label="Primary navigation">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const activeCount = sessions.filter(
|
||||
(s) => s.status === "running",
|
||||
).length;
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={({ isActive }) =>
|
||||
isActive ? "nav-item nav-item-active" : "nav-item"
|
||||
}
|
||||
end={item.to === "/"}
|
||||
>
|
||||
<Icon name={item.icon} size="sm" />
|
||||
{item.label}
|
||||
{item.badge === "sessions" && activeCount > 0 && (
|
||||
<span className="nav-badge">{activeCount}</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
|
||||
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
{sessions.length > 0 && (
|
||||
<>
|
||||
<div className="nav-divider" />
|
||||
<div className="nav-section-title">Live sessions</div>
|
||||
{sessions.map((session) => (
|
||||
<SessionItem key={session.id} session={session} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{isMobile && (
|
||||
<MobileNav sessionCount={sessions.filter((s) => s.status === "running").length} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
<main className={`shell-content ${isMobile ? "mobile" : ""}`}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{isMobile && (
|
||||
<MobileNav
|
||||
sessionCount={
|
||||
sessions.filter((s) => s.status === "running").length
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</EventProvider>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user