Add instance heartbeat settings sidebar

This commit is contained in:
Dotta
2026-03-12 08:03:55 -05:00
parent 369dfa4397
commit 32bdcf1dca
12 changed files with 250 additions and 8 deletions

View File

@@ -23,6 +23,7 @@ import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { DesignGuide } from "./pages/DesignGuide";
import { InstanceSettings } from "./pages/InstanceSettings";
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
import { OrgChart } from "./pages/OrgChart";
import { NewAgent } from "./pages/NewAgent";
@@ -109,6 +110,8 @@ function boardRoutes() {
<Route path="dashboard" element={<Dashboard />} />
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="org" element={<OrgChart />} />
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
<Route path="agents/all" element={<Agents />} />
@@ -156,6 +159,11 @@ function InboxRootRedirect() {
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
}
function LegacySettingsRedirect() {
const location = useLocation();
return <Navigate to={`/instance/settings${location.search}${location.hash}`} replace />;
}
function CompanyRootRedirect() {
const { companies, selectedCompany, loading } = useCompany();
const { onboardingOpen } = useDialog();
@@ -234,9 +242,15 @@ export function App() {
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
<Route path="instance" element={<Navigate to="/instance/settings" replace />} />
<Route path="instance/settings" element={<Layout />}>
<Route index element={<InstanceSettings />} />
</Route>
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="settings" element={<LegacySettingsRedirect />} />
<Route path="settings/*" element={<LegacySettingsRedirect />} />
<Route path="agents" element={<UnprefixedBoardRedirect />} />
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />

View File

@@ -1,4 +1,8 @@
import type { HeartbeatRun, HeartbeatRunEvent } from "@paperclipai/shared";
import type {
HeartbeatRun,
HeartbeatRunEvent,
InstanceSchedulerHeartbeatAgent,
} from "@paperclipai/shared";
import { api } from "./client";
export interface ActiveRunForIssue extends HeartbeatRun {
@@ -45,4 +49,6 @@ export const heartbeatsApi = {
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
liveRunsForCompany: (companyId: string, minCount?: number) =>
api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${minCount ? `?minCount=${minCount}` : ""}`),
listInstanceSchedulerAgents: () =>
api.get<InstanceSchedulerHeartbeatAgent[]>("/instance/scheduler-heartbeats"),
};

View File

@@ -22,6 +22,7 @@ import { cn } from "../lib/utils";
import { queryKeys } from "../lib/queryKeys";
import { sidebarBadgesApi } from "../api/sidebarBadges";
import { heartbeatsApi } from "../api/heartbeats";
import { useLocation, useNavigate } from "@/lib/router";
import {
Tooltip,
TooltipContent,
@@ -154,6 +155,10 @@ function SortableCompanyItem({
export function CompanyRail() {
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { openOnboarding } = useDialog();
const navigate = useNavigate();
const location = useLocation();
const isInstanceRoute = location.pathname.startsWith("/instance/");
const highlightedCompanyId = isInstanceRoute ? null : selectedCompanyId;
const sidebarCompanies = useMemo(
() => companies.filter((company) => company.status !== "archived"),
[companies],
@@ -282,10 +287,15 @@ export function CompanyRail() {
<SortableCompanyItem
key={company.id}
company={company}
isSelected={company.id === selectedCompanyId}
isSelected={company.id === highlightedCompanyId}
hasLiveAgents={hasLiveAgentsByCompanyId.get(company.id) ?? false}
hasUnreadInbox={hasUnreadInboxByCompanyId.get(company.id) ?? false}
onSelect={() => setSelectedCompanyId(company.id)}
onSelect={() => {
setSelectedCompanyId(company.id);
if (isInstanceRoute) {
navigate(`/${company.issuePrefix}/dashboard`);
}
}}
/>
))}
</SortableContext>

View File

@@ -0,0 +1,21 @@
import { Clock3, Settings } from "lucide-react";
import { SidebarNavItem } from "./SidebarNavItem";
export function InstanceSidebar() {
return (
<aside className="w-60 h-full min-h-0 border-r border-border bg-background flex flex-col">
<div className="flex items-center gap-2 px-3 h-12 shrink-0">
<Settings className="h-4 w-4 text-muted-foreground shrink-0 ml-1" />
<span className="flex-1 text-sm font-bold text-foreground truncate">
Instance Settings
</span>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto scrollbar-auto-hide flex flex-col gap-4 px-3 py-2">
<div className="flex flex-col gap-0.5">
<SidebarNavItem to="/instance/settings" label="Heartbeats" icon={Clock3} />
</div>
</nav>
</aside>
);
}

View File

@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Moon, Sun } from "lucide-react";
import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
import { CompanyRail } from "./CompanyRail";
import { Sidebar } from "./Sidebar";
import { InstanceSidebar } from "./InstanceSidebar";
import { SidebarNavItem } from "./SidebarNavItem";
import { BreadcrumbBar } from "./BreadcrumbBar";
import { PropertiesPanel } from "./PropertiesPanel";
@@ -42,6 +43,7 @@ export function Layout() {
const { companyPrefix } = useParams<{ companyPrefix: string }>();
const navigate = useNavigate();
const location = useLocation();
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
const onboardingTriggered = useRef(false);
const lastMainScrollTop = useRef(0);
const [mobileNavVisible, setMobileNavVisible] = useState(true);
@@ -242,7 +244,7 @@ export function Layout() {
>
<div className="flex flex-1 min-h-0 overflow-hidden">
<CompanyRail />
<Sidebar />
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
</div>
<div className="border-t border-r border-border px-3 py-2 bg-background">
<div className="flex items-center gap-1">
@@ -252,6 +254,18 @@ export function Layout() {
icon={BookOpen}
className="flex-1 min-w-0"
/>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to="/instance/settings"
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Settings className="h-4 w-4" />
</Link>
</Button>
<Button
type="button"
variant="ghost"
@@ -276,7 +290,7 @@ export function Layout() {
sidebarOpen ? "w-60" : "w-0"
)}
>
<Sidebar />
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
</div>
</div>
<div className="border-t border-r border-border px-3 py-2">
@@ -287,6 +301,18 @@ export function Layout() {
icon={BookOpen}
className="flex-1 min-w-0"
/>
<Button variant="ghost" size="icon-sm" className="text-muted-foreground shrink-0" asChild>
<Link
to="/instance/settings"
aria-label="Instance settings"
title="Instance settings"
onClick={() => {
if (isMobile) setSidebarOpen(false);
}}
>
<Settings className="h-4 w-4" />
</Link>
</Button>
<Button
type="button"
variant="ghost"

View File

@@ -14,7 +14,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"design-guide",
]);
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "docs"]);
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "docs", "instance"]);
export function normalizeCompanyPrefix(prefix: string): string {
return prefix.trim().toUpperCase();

View File

@@ -56,6 +56,9 @@ export const queryKeys = {
auth: {
session: ["auth", "session"] as const,
},
instance: {
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
},
health: ["health"] as const,
secrets: {
list: (companyId: string) => ["secrets", companyId] as const,