Merge pull request #697 from paperclipai/paperclip_instance_sidebar_v2
Add instance heartbeat settings sidebar
This commit is contained in:
@@ -99,6 +99,7 @@ export type {
|
|||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
AgentTaskSession,
|
AgentTaskSession,
|
||||||
AgentWakeupRequest,
|
AgentWakeupRequest,
|
||||||
|
InstanceSchedulerHeartbeatAgent,
|
||||||
LiveEvent,
|
LiveEvent,
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
ActivityEvent,
|
ActivityEvent,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
|
AgentRole,
|
||||||
|
AgentStatus,
|
||||||
HeartbeatInvocationSource,
|
HeartbeatInvocationSource,
|
||||||
HeartbeatRunStatus,
|
HeartbeatRunStatus,
|
||||||
WakeupTriggerDetail,
|
WakeupTriggerDetail,
|
||||||
@@ -105,3 +107,20 @@ export interface AgentWakeupRequest {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InstanceSchedulerHeartbeatAgent {
|
||||||
|
id: string;
|
||||||
|
companyId: string;
|
||||||
|
companyName: string;
|
||||||
|
companyIssuePrefix: string;
|
||||||
|
agentName: string;
|
||||||
|
agentUrlKey: string;
|
||||||
|
role: AgentRole;
|
||||||
|
title: string | null;
|
||||||
|
status: AgentStatus;
|
||||||
|
adapterType: string;
|
||||||
|
intervalSec: number;
|
||||||
|
heartbeatEnabled: boolean;
|
||||||
|
schedulerActive: boolean;
|
||||||
|
lastHeartbeatAt: Date | null;
|
||||||
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export type {
|
|||||||
AgentRuntimeState,
|
AgentRuntimeState,
|
||||||
AgentTaskSession,
|
AgentTaskSession,
|
||||||
AgentWakeupRequest,
|
AgentWakeupRequest,
|
||||||
|
InstanceSchedulerHeartbeatAgent,
|
||||||
} from "./heartbeat.js";
|
} from "./heartbeat.js";
|
||||||
export type { LiveEvent } from "./live.js";
|
export type { LiveEvent } from "./live.js";
|
||||||
export type { DashboardSummary } from "./dashboard.js";
|
export type { DashboardSummary } from "./dashboard.js";
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
createAgentKeySchema,
|
createAgentKeySchema,
|
||||||
createAgentHireSchema,
|
createAgentHireSchema,
|
||||||
createAgentSchema,
|
createAgentSchema,
|
||||||
|
deriveAgentUrlKey,
|
||||||
isUuidLike,
|
isUuidLike,
|
||||||
resetAgentSessionSchema,
|
resetAgentSessionSchema,
|
||||||
testAdapterEnvironmentSchema,
|
testAdapterEnvironmentSchema,
|
||||||
|
type InstanceSchedulerHeartbeatAgent,
|
||||||
updateAgentPermissionsSchema,
|
updateAgentPermissionsSchema,
|
||||||
updateAgentInstructionsPathSchema,
|
updateAgentInstructionsPathSchema,
|
||||||
wakeAgentSchema,
|
wakeAgentSchema,
|
||||||
@@ -202,6 +204,21 @@ export function agentRoutes(db: Db) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseNumberLike(value: unknown): number | null {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const parsed = Number(value.trim());
|
||||||
|
return Number.isFinite(parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSchedulerHeartbeatPolicy(runtimeConfig: unknown) {
|
||||||
|
const heartbeat = asRecord(asRecord(runtimeConfig)?.heartbeat) ?? {};
|
||||||
|
return {
|
||||||
|
enabled: parseBooleanLike(heartbeat.enabled) ?? true,
|
||||||
|
intervalSec: Math.max(0, parseNumberLike(heartbeat.intervalSec) ?? 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function generateEd25519PrivateKeyPem(): string {
|
function generateEd25519PrivateKeyPem(): string {
|
||||||
const { privateKey } = generateKeyPairSync("ed25519");
|
const { privateKey } = generateKeyPairSync("ed25519");
|
||||||
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||||
@@ -454,6 +471,81 @@ export function agentRoutes(db: Db) {
|
|||||||
res.json(result.map((agent) => redactForRestrictedAgentView(agent)));
|
res.json(result.map((agent) => redactForRestrictedAgentView(agent)));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/instance/scheduler-heartbeats", async (req, res) => {
|
||||||
|
assertBoard(req);
|
||||||
|
|
||||||
|
const accessConditions = [];
|
||||||
|
if (req.actor.source !== "local_implicit" && !req.actor.isInstanceAdmin) {
|
||||||
|
const allowedCompanyIds = req.actor.companyIds ?? [];
|
||||||
|
if (allowedCompanyIds.length === 0) {
|
||||||
|
res.json([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
accessConditions.push(inArray(agentsTable.companyId, allowedCompanyIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: agentsTable.id,
|
||||||
|
companyId: agentsTable.companyId,
|
||||||
|
agentName: agentsTable.name,
|
||||||
|
role: agentsTable.role,
|
||||||
|
title: agentsTable.title,
|
||||||
|
status: agentsTable.status,
|
||||||
|
adapterType: agentsTable.adapterType,
|
||||||
|
runtimeConfig: agentsTable.runtimeConfig,
|
||||||
|
lastHeartbeatAt: agentsTable.lastHeartbeatAt,
|
||||||
|
companyName: companies.name,
|
||||||
|
companyIssuePrefix: companies.issuePrefix,
|
||||||
|
})
|
||||||
|
.from(agentsTable)
|
||||||
|
.innerJoin(companies, eq(agentsTable.companyId, companies.id))
|
||||||
|
.where(accessConditions.length > 0 ? and(...accessConditions) : undefined)
|
||||||
|
.orderBy(companies.name, agentsTable.name);
|
||||||
|
|
||||||
|
const items: InstanceSchedulerHeartbeatAgent[] = rows
|
||||||
|
.map((row) => {
|
||||||
|
const policy = parseSchedulerHeartbeatPolicy(row.runtimeConfig);
|
||||||
|
const statusEligible =
|
||||||
|
row.status !== "paused" &&
|
||||||
|
row.status !== "terminated" &&
|
||||||
|
row.status !== "pending_approval";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
companyId: row.companyId,
|
||||||
|
companyName: row.companyName,
|
||||||
|
companyIssuePrefix: row.companyIssuePrefix,
|
||||||
|
agentName: row.agentName,
|
||||||
|
agentUrlKey: deriveAgentUrlKey(row.agentName, row.id),
|
||||||
|
role: row.role as InstanceSchedulerHeartbeatAgent["role"],
|
||||||
|
title: row.title,
|
||||||
|
status: row.status as InstanceSchedulerHeartbeatAgent["status"],
|
||||||
|
adapterType: row.adapterType,
|
||||||
|
intervalSec: policy.intervalSec,
|
||||||
|
heartbeatEnabled: policy.enabled,
|
||||||
|
schedulerActive: statusEligible && policy.enabled && policy.intervalSec > 0,
|
||||||
|
lastHeartbeatAt: row.lastHeartbeatAt,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((item) =>
|
||||||
|
item.intervalSec > 0 &&
|
||||||
|
item.status !== "paused" &&
|
||||||
|
item.status !== "terminated" &&
|
||||||
|
item.status !== "pending_approval",
|
||||||
|
)
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.schedulerActive !== right.schedulerActive) {
|
||||||
|
return left.schedulerActive ? -1 : 1;
|
||||||
|
}
|
||||||
|
const companyOrder = left.companyName.localeCompare(right.companyName);
|
||||||
|
if (companyOrder !== 0) return companyOrder;
|
||||||
|
return left.agentName.localeCompare(right.agentName);
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(items);
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/companies/:companyId/org", async (req, res) => {
|
router.get("/companies/:companyId/org", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { Activity } from "./pages/Activity";
|
|||||||
import { Inbox } from "./pages/Inbox";
|
import { Inbox } from "./pages/Inbox";
|
||||||
import { CompanySettings } from "./pages/CompanySettings";
|
import { CompanySettings } from "./pages/CompanySettings";
|
||||||
import { DesignGuide } from "./pages/DesignGuide";
|
import { DesignGuide } from "./pages/DesignGuide";
|
||||||
|
import { InstanceSettings } from "./pages/InstanceSettings";
|
||||||
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
|
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
|
||||||
import { OrgChart } from "./pages/OrgChart";
|
import { OrgChart } from "./pages/OrgChart";
|
||||||
import { NewAgent } from "./pages/NewAgent";
|
import { NewAgent } from "./pages/NewAgent";
|
||||||
@@ -109,6 +110,8 @@ function boardRoutes() {
|
|||||||
<Route path="dashboard" element={<Dashboard />} />
|
<Route path="dashboard" element={<Dashboard />} />
|
||||||
<Route path="companies" element={<Companies />} />
|
<Route path="companies" element={<Companies />} />
|
||||||
<Route path="company/settings" element={<CompanySettings />} />
|
<Route path="company/settings" element={<CompanySettings />} />
|
||||||
|
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||||
|
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||||
<Route path="org" element={<OrgChart />} />
|
<Route path="org" element={<OrgChart />} />
|
||||||
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
|
<Route path="agents" element={<Navigate to="/agents/all" replace />} />
|
||||||
<Route path="agents/all" element={<Agents />} />
|
<Route path="agents/all" element={<Agents />} />
|
||||||
@@ -156,6 +159,11 @@ function InboxRootRedirect() {
|
|||||||
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
|
return <Navigate to={`/inbox/${loadLastInboxTab()}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LegacySettingsRedirect() {
|
||||||
|
const location = useLocation();
|
||||||
|
return <Navigate to={`/instance/settings${location.search}${location.hash}`} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
function CompanyRootRedirect() {
|
function CompanyRootRedirect() {
|
||||||
const { companies, selectedCompany, loading } = useCompany();
|
const { companies, selectedCompany, loading } = useCompany();
|
||||||
const { onboardingOpen } = useDialog();
|
const { onboardingOpen } = useDialog();
|
||||||
@@ -234,9 +242,15 @@ export function App() {
|
|||||||
|
|
||||||
<Route element={<CloudAccessGate />}>
|
<Route element={<CloudAccessGate />}>
|
||||||
<Route index element={<CompanyRootRedirect />} />
|
<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="companies" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="issues/:issueId" 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" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
|
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
|
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { HeartbeatRun, HeartbeatRunEvent } from "@paperclipai/shared";
|
import type {
|
||||||
|
HeartbeatRun,
|
||||||
|
HeartbeatRunEvent,
|
||||||
|
InstanceSchedulerHeartbeatAgent,
|
||||||
|
} from "@paperclipai/shared";
|
||||||
import { api } from "./client";
|
import { api } from "./client";
|
||||||
|
|
||||||
export interface ActiveRunForIssue extends HeartbeatRun {
|
export interface ActiveRunForIssue extends HeartbeatRun {
|
||||||
@@ -45,4 +49,6 @@ export const heartbeatsApi = {
|
|||||||
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
|
api.get<ActiveRunForIssue | null>(`/issues/${issueId}/active-run`),
|
||||||
liveRunsForCompany: (companyId: string, minCount?: number) =>
|
liveRunsForCompany: (companyId: string, minCount?: number) =>
|
||||||
api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${minCount ? `?minCount=${minCount}` : ""}`),
|
api.get<LiveRunForIssue[]>(`/companies/${companyId}/live-runs${minCount ? `?minCount=${minCount}` : ""}`),
|
||||||
|
listInstanceSchedulerAgents: () =>
|
||||||
|
api.get<InstanceSchedulerHeartbeatAgent[]>("/instance/scheduler-heartbeats"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { cn } from "../lib/utils";
|
|||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
import { sidebarBadgesApi } from "../api/sidebarBadges";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
|
import { useLocation, useNavigate } from "@/lib/router";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -154,6 +155,10 @@ function SortableCompanyItem({
|
|||||||
export function CompanyRail() {
|
export function CompanyRail() {
|
||||||
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
||||||
const { openOnboarding } = useDialog();
|
const { openOnboarding } = useDialog();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const isInstanceRoute = location.pathname.startsWith("/instance/");
|
||||||
|
const highlightedCompanyId = isInstanceRoute ? null : selectedCompanyId;
|
||||||
const sidebarCompanies = useMemo(
|
const sidebarCompanies = useMemo(
|
||||||
() => companies.filter((company) => company.status !== "archived"),
|
() => companies.filter((company) => company.status !== "archived"),
|
||||||
[companies],
|
[companies],
|
||||||
@@ -282,10 +287,15 @@ export function CompanyRail() {
|
|||||||
<SortableCompanyItem
|
<SortableCompanyItem
|
||||||
key={company.id}
|
key={company.id}
|
||||||
company={company}
|
company={company}
|
||||||
isSelected={company.id === selectedCompanyId}
|
isSelected={company.id === highlightedCompanyId}
|
||||||
hasLiveAgents={hasLiveAgentsByCompanyId.get(company.id) ?? false}
|
hasLiveAgents={hasLiveAgentsByCompanyId.get(company.id) ?? false}
|
||||||
hasUnreadInbox={hasUnreadInboxByCompanyId.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>
|
</SortableContext>
|
||||||
|
|||||||
21
ui/src/components/InstanceSidebar.tsx
Normal file
21
ui/src/components/InstanceSidebar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { BookOpen, Moon, Sun } from "lucide-react";
|
import { BookOpen, Moon, Settings, Sun } from "lucide-react";
|
||||||
import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
|
import { Link, Outlet, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { CompanyRail } from "./CompanyRail";
|
import { CompanyRail } from "./CompanyRail";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
|
import { InstanceSidebar } from "./InstanceSidebar";
|
||||||
import { SidebarNavItem } from "./SidebarNavItem";
|
import { SidebarNavItem } from "./SidebarNavItem";
|
||||||
import { BreadcrumbBar } from "./BreadcrumbBar";
|
import { BreadcrumbBar } from "./BreadcrumbBar";
|
||||||
import { PropertiesPanel } from "./PropertiesPanel";
|
import { PropertiesPanel } from "./PropertiesPanel";
|
||||||
@@ -42,6 +43,7 @@ export function Layout() {
|
|||||||
const { companyPrefix } = useParams<{ companyPrefix: string }>();
|
const { companyPrefix } = useParams<{ companyPrefix: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const isInstanceSettingsRoute = location.pathname.startsWith("/instance/");
|
||||||
const onboardingTriggered = useRef(false);
|
const onboardingTriggered = useRef(false);
|
||||||
const lastMainScrollTop = useRef(0);
|
const lastMainScrollTop = useRef(0);
|
||||||
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
const [mobileNavVisible, setMobileNavVisible] = useState(true);
|
||||||
@@ -242,7 +244,7 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||||
<CompanyRail />
|
<CompanyRail />
|
||||||
<Sidebar />
|
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
<div className="border-t border-r border-border px-3 py-2 bg-background">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -252,6 +254,18 @@ export function Layout() {
|
|||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
className="flex-1 min-w-0"
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -276,7 +290,7 @@ export function Layout() {
|
|||||||
sidebarOpen ? "w-60" : "w-0"
|
sidebarOpen ? "w-60" : "w-0"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Sidebar />
|
{isInstanceSettingsRoute ? <InstanceSidebar /> : <Sidebar />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-r border-border px-3 py-2">
|
<div className="border-t border-r border-border px-3 py-2">
|
||||||
@@ -287,6 +301,18 @@ export function Layout() {
|
|||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
className="flex-1 min-w-0"
|
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
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
|||||||
"design-guide",
|
"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 {
|
export function normalizeCompanyPrefix(prefix: string): string {
|
||||||
return prefix.trim().toUpperCase();
|
return prefix.trim().toUpperCase();
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ export const queryKeys = {
|
|||||||
auth: {
|
auth: {
|
||||||
session: ["auth", "session"] as const,
|
session: ["auth", "session"] as const,
|
||||||
},
|
},
|
||||||
|
instance: {
|
||||||
|
schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const,
|
||||||
|
},
|
||||||
health: ["health"] as const,
|
health: ["health"] as const,
|
||||||
secrets: {
|
secrets: {
|
||||||
list: (companyId: string) => ["secrets", companyId] as const,
|
list: (companyId: string) => ["secrets", companyId] as const,
|
||||||
|
|||||||
211
ui/src/pages/InstanceSettings.tsx
Normal file
211
ui/src/pages/InstanceSettings.tsx
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Clock3, ExternalLink, Settings } from "lucide-react";
|
||||||
|
import type { InstanceSchedulerHeartbeatAgent } from "@paperclipai/shared";
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { EmptyState } from "../components/EmptyState";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { formatDateTime, relativeTime } from "../lib/utils";
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function humanize(value: string) {
|
||||||
|
return value.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgentHref(agent: InstanceSchedulerHeartbeatAgent) {
|
||||||
|
return `/${agent.companyIssuePrefix}/agents/${encodeURIComponent(agent.agentUrlKey)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InstanceSettings() {
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBreadcrumbs([
|
||||||
|
{ label: "Instance Settings" },
|
||||||
|
{ label: "Heartbeats" },
|
||||||
|
]);
|
||||||
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
|
const heartbeatsQuery = useQuery({
|
||||||
|
queryKey: queryKeys.instance.schedulerHeartbeats,
|
||||||
|
queryFn: () => heartbeatsApi.listInstanceSchedulerAgents(),
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleMutation = useMutation({
|
||||||
|
mutationFn: async (agentRow: InstanceSchedulerHeartbeatAgent) => {
|
||||||
|
const agent = await agentsApi.get(agentRow.id, agentRow.companyId);
|
||||||
|
const runtimeConfig = asRecord(agent.runtimeConfig) ?? {};
|
||||||
|
const heartbeat = asRecord(runtimeConfig.heartbeat) ?? {};
|
||||||
|
|
||||||
|
return agentsApi.update(
|
||||||
|
agentRow.id,
|
||||||
|
{
|
||||||
|
runtimeConfig: {
|
||||||
|
...runtimeConfig,
|
||||||
|
heartbeat: {
|
||||||
|
...heartbeat,
|
||||||
|
enabled: !agentRow.heartbeatEnabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
agentRow.companyId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async (_, agentRow) => {
|
||||||
|
setActionError(null);
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.instance.schedulerHeartbeats }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(agentRow.companyId) }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agentRow.id) }),
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setActionError(error instanceof Error ? error.message : "Failed to update heartbeat.");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const agents = heartbeatsQuery.data ?? [];
|
||||||
|
const activeCount = agents.filter((agent) => agent.schedulerActive).length;
|
||||||
|
const disabledCount = agents.length - activeCount;
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, { companyName: string; agents: InstanceSchedulerHeartbeatAgent[] }>();
|
||||||
|
for (const agent of agents) {
|
||||||
|
let group = map.get(agent.companyId);
|
||||||
|
if (!group) {
|
||||||
|
group = { companyName: agent.companyName, agents: [] };
|
||||||
|
map.set(agent.companyId, group);
|
||||||
|
}
|
||||||
|
group.agents.push(agent);
|
||||||
|
}
|
||||||
|
return [...map.values()];
|
||||||
|
}, [agents]);
|
||||||
|
|
||||||
|
if (heartbeatsQuery.isLoading) {
|
||||||
|
return <div className="text-sm text-muted-foreground">Loading scheduler heartbeats...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heartbeatsQuery.error) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
{heartbeatsQuery.error instanceof Error
|
||||||
|
? heartbeatsQuery.error.message
|
||||||
|
: "Failed to load scheduler heartbeats."}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-5xl space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||||
|
<h1 className="text-lg font-semibold">Scheduler Heartbeats</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Agents with a timer heartbeat enabled across all of your companies.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||||
|
<span><span className="font-semibold text-foreground">{activeCount}</span> active</span>
|
||||||
|
<span><span className="font-semibold text-foreground">{disabledCount}</span> disabled</span>
|
||||||
|
<span><span className="font-semibold text-foreground">{grouped.length}</span> {grouped.length === 1 ? "company" : "companies"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{actionError && (
|
||||||
|
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||||
|
{actionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Clock3}
|
||||||
|
message="No scheduler heartbeats match the current criteria."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{grouped.map((group) => (
|
||||||
|
<Card key={group.companyName}>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="border-b px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{group.companyName}
|
||||||
|
</div>
|
||||||
|
<div className="divide-y">
|
||||||
|
{group.agents.map((agent) => {
|
||||||
|
const saving = toggleMutation.isPending && toggleMutation.variables?.id === agent.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={agent.id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<Badge
|
||||||
|
variant={agent.schedulerActive ? "default" : "outline"}
|
||||||
|
className="shrink-0 text-[10px] px-1.5 py-0"
|
||||||
|
>
|
||||||
|
{agent.schedulerActive ? "On" : "Off"}
|
||||||
|
</Badge>
|
||||||
|
<Link
|
||||||
|
to={buildAgentHref(agent)}
|
||||||
|
className="font-medium truncate hover:underline"
|
||||||
|
>
|
||||||
|
{agent.agentName}
|
||||||
|
</Link>
|
||||||
|
<span className="hidden sm:inline text-muted-foreground truncate">
|
||||||
|
{humanize(agent.title ?? agent.role)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground tabular-nums shrink-0">
|
||||||
|
{agent.intervalSec}s
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="hidden md:inline text-muted-foreground truncate"
|
||||||
|
title={agent.lastHeartbeatAt ? formatDateTime(agent.lastHeartbeatAt) : undefined}
|
||||||
|
>
|
||||||
|
{agent.lastHeartbeatAt
|
||||||
|
? relativeTime(agent.lastHeartbeatAt)
|
||||||
|
: "never"}
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto flex items-center gap-1.5 shrink-0">
|
||||||
|
<Link
|
||||||
|
to={buildAgentHref(agent)}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
title="Full agent config"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => toggleMutation.mutate(agent)}
|
||||||
|
>
|
||||||
|
{saving ? "..." : agent.heartbeatEnabled ? "Disable Timer Heartbeat" : "Enable Timer Heartbeat"}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user