feat(ui): add board auto heartbeat governor settings section
This commit is contained in:
@@ -7,9 +7,9 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
|
|||||||
|
|
||||||
import { useAuth } from "@/auth/clerk";
|
import { useAuth } from "@/auth/clerk";
|
||||||
import { X } from "lucide-react";
|
import { X } from "lucide-react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError, customFetch } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
type getBoardApiV1BoardsBoardIdGetResponse,
|
type getBoardApiV1BoardsBoardIdGetResponse,
|
||||||
useGetBoardApiV1BoardsBoardIdGet,
|
useGetBoardApiV1BoardsBoardIdGet,
|
||||||
@@ -69,6 +69,22 @@ const slugify = (value: string) =>
|
|||||||
|
|
||||||
const LEAD_AGENT_VALUE = "__lead_agent__";
|
const LEAD_AGENT_VALUE = "__lead_agent__";
|
||||||
|
|
||||||
|
type GovernorActivityTriggerType = "A" | "B";
|
||||||
|
|
||||||
|
type AutoHeartbeatGovernorPolicy = {
|
||||||
|
enabled: boolean;
|
||||||
|
run_interval_seconds: number;
|
||||||
|
ladder: string[];
|
||||||
|
lead_cap_every: string;
|
||||||
|
activity_trigger_type: GovernorActivityTriggerType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const governorPolicyQueryKey = (boardId: string) => [
|
||||||
|
"boards",
|
||||||
|
boardId,
|
||||||
|
"auto-heartbeat-governor-policy",
|
||||||
|
] as const;
|
||||||
|
|
||||||
type WebhookCardProps = {
|
type WebhookCardProps = {
|
||||||
webhook: BoardWebhookRead;
|
webhook: BoardWebhookRead;
|
||||||
agents: AgentRead[];
|
agents: AgentRead[];
|
||||||
@@ -311,6 +327,16 @@ export default function EditBoardPage() {
|
|||||||
const [webhookError, setWebhookError] = useState<string | null>(null);
|
const [webhookError, setWebhookError] = useState<string | null>(null);
|
||||||
const [copiedWebhookId, setCopiedWebhookId] = useState<string | null>(null);
|
const [copiedWebhookId, setCopiedWebhookId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [governorPolicyDraft, setGovernorPolicyDraft] = useState<
|
||||||
|
AutoHeartbeatGovernorPolicy | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [governorPolicyError, setGovernorPolicyError] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [governorPolicySaveSuccess, setGovernorPolicySaveSuccess] = useState<
|
||||||
|
string | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const onboardingParam = searchParams.get("onboarding");
|
const onboardingParam = searchParams.get("onboarding");
|
||||||
const searchParamsString = searchParams.toString();
|
const searchParamsString = searchParams.toString();
|
||||||
const shouldAutoOpenOnboarding =
|
const shouldAutoOpenOnboarding =
|
||||||
@@ -419,6 +445,59 @@ export default function EditBoardPage() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const governorPolicyQuery = useQuery({
|
||||||
|
queryKey: boardId ? governorPolicyQueryKey(boardId) : [],
|
||||||
|
enabled: Boolean(isSignedIn && isAdmin && boardId),
|
||||||
|
retry: false,
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!boardId) return null;
|
||||||
|
const resp = await customFetch<{ data: AutoHeartbeatGovernorPolicy }>(
|
||||||
|
`/api/v1/boards/${boardId}/auto-heartbeat-governor-policy`,
|
||||||
|
{ method: "GET" },
|
||||||
|
);
|
||||||
|
return resp.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!governorPolicyQuery.data) return;
|
||||||
|
setGovernorPolicyDraft(governorPolicyQuery.data);
|
||||||
|
}, [governorPolicyQuery.data]);
|
||||||
|
|
||||||
|
const saveGovernorPolicyMutation = useMutation({
|
||||||
|
mutationFn: async (policy: Partial<AutoHeartbeatGovernorPolicy>) => {
|
||||||
|
if (!boardId) throw new Error("Missing board id");
|
||||||
|
const resp = await customFetch<{ data: AutoHeartbeatGovernorPolicy }>(
|
||||||
|
`/api/v1/boards/${boardId}/auto-heartbeat-governor-policy`,
|
||||||
|
{
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(policy),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return resp.data;
|
||||||
|
},
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
if (!boardId) return;
|
||||||
|
setGovernorPolicyError(null);
|
||||||
|
setGovernorPolicySaveSuccess("Governor policy saved.");
|
||||||
|
setGovernorPolicyDraft(data);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: governorPolicyQueryKey(boardId),
|
||||||
|
});
|
||||||
|
window.setTimeout(() => setGovernorPolicySaveSuccess(null), 2500);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
const message =
|
||||||
|
err instanceof ApiError
|
||||||
|
? err.message
|
||||||
|
: err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Unable to save governor policy.";
|
||||||
|
setGovernorPolicySaveSuccess(null);
|
||||||
|
setGovernorPolicyError(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const updateBoardMutation = useUpdateBoardApiV1BoardsBoardIdPatch<ApiError>({
|
const updateBoardMutation = useUpdateBoardApiV1BoardsBoardIdPatch<ApiError>({
|
||||||
mutation: {
|
mutation: {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
@@ -538,12 +617,16 @@ export default function EditBoardPage() {
|
|||||||
gatewaysQuery.isLoading ||
|
gatewaysQuery.isLoading ||
|
||||||
groupsQuery.isLoading ||
|
groupsQuery.isLoading ||
|
||||||
boardQuery.isLoading ||
|
boardQuery.isLoading ||
|
||||||
|
governorPolicyQuery.isLoading ||
|
||||||
updateBoardMutation.isPending;
|
updateBoardMutation.isPending;
|
||||||
const errorMessage =
|
const errorMessage =
|
||||||
error ??
|
error ??
|
||||||
gatewaysQuery.error?.message ??
|
gatewaysQuery.error?.message ??
|
||||||
groupsQuery.error?.message ??
|
groupsQuery.error?.message ??
|
||||||
boardQuery.error?.message ??
|
boardQuery.error?.message ??
|
||||||
|
(governorPolicyQuery.error instanceof Error
|
||||||
|
? governorPolicyQuery.error.message
|
||||||
|
: null) ??
|
||||||
null;
|
null;
|
||||||
const webhookErrorMessage =
|
const webhookErrorMessage =
|
||||||
webhookError ??
|
webhookError ??
|
||||||
@@ -1087,6 +1170,203 @@ export default function EditBoardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-4 border-t border-slate-200 pt-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold text-slate-900">
|
||||||
|
Auto heartbeat governor
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
Controls how Mission Control automatically backs off agent
|
||||||
|
heartbeats when this board is idle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{governorPolicySaveSuccess ? (
|
||||||
|
<p className="text-sm text-emerald-600">
|
||||||
|
{governorPolicySaveSuccess}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{governorPolicyError ? (
|
||||||
|
<p className="text-sm text-red-500">{governorPolicyError}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{governorPolicyDraft ? (
|
||||||
|
<div className="space-y-4 rounded-lg border border-slate-200 px-4 py-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={governorPolicyDraft.enabled}
|
||||||
|
aria-label="Enable auto heartbeat governor"
|
||||||
|
onClick={() => {
|
||||||
|
setGovernorPolicySaveSuccess(null);
|
||||||
|
setGovernorPolicyError(null);
|
||||||
|
setGovernorPolicyDraft({
|
||||||
|
...governorPolicyDraft,
|
||||||
|
enabled: !governorPolicyDraft.enabled,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading || saveGovernorPolicyMutation.isPending}
|
||||||
|
className={`mt-0.5 inline-flex h-6 w-11 shrink-0 items-center rounded-full border transition ${
|
||||||
|
governorPolicyDraft.enabled
|
||||||
|
? "border-emerald-600 bg-emerald-600"
|
||||||
|
: "border-slate-300 bg-slate-200"
|
||||||
|
} ${
|
||||||
|
isLoading || saveGovernorPolicyMutation.isPending
|
||||||
|
? "cursor-not-allowed opacity-60"
|
||||||
|
: "cursor-pointer"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition ${
|
||||||
|
governorPolicyDraft.enabled
|
||||||
|
? "translate-x-5"
|
||||||
|
: "translate-x-0.5"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span className="space-y-1">
|
||||||
|
<span className="block text-sm font-medium text-slate-900">
|
||||||
|
Enabled
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-slate-600">
|
||||||
|
If disabled, the governor will not manage agent heartbeats
|
||||||
|
for this board.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Run interval (seconds)
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={30}
|
||||||
|
step={1}
|
||||||
|
value={governorPolicyDraft.run_interval_seconds}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number.parseInt(event.target.value, 10);
|
||||||
|
setGovernorPolicyDraft({
|
||||||
|
...governorPolicyDraft,
|
||||||
|
run_interval_seconds: Number.isNaN(next)
|
||||||
|
? 300
|
||||||
|
: Math.max(30, next),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading || saveGovernorPolicyMutation.isPending}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Hint for cadence; backend enforces 30s minimum.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Activity trigger type
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={governorPolicyDraft.activity_trigger_type}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setGovernorPolicyDraft({
|
||||||
|
...governorPolicyDraft,
|
||||||
|
activity_trigger_type: value as GovernorActivityTriggerType,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading || saveGovernorPolicyMutation.isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select trigger" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="B">B — chat or assigned work</SelectItem>
|
||||||
|
<SelectItem value="A">A — chat only</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 md:col-span-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Ladder values
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={governorPolicyDraft.ladder.join(", ")}
|
||||||
|
onChange={(event) => {
|
||||||
|
const ladder = event.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
setGovernorPolicyDraft({
|
||||||
|
...governorPolicyDraft,
|
||||||
|
ladder,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="10m, 30m, 1h, 3h, 6h"
|
||||||
|
disabled={isLoading || saveGovernorPolicyMutation.isPending}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Comma-separated durations (e.g. 10m, 1h). Non-leads go
|
||||||
|
fully off after the last rung.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Lead cap
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={governorPolicyDraft.lead_cap_every}
|
||||||
|
onChange={(event) =>
|
||||||
|
setGovernorPolicyDraft({
|
||||||
|
...governorPolicyDraft,
|
||||||
|
lead_cap_every: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
placeholder="1h"
|
||||||
|
disabled={isLoading || saveGovernorPolicyMutation.isPending}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Leads never go fully off; they cap at this value.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (!governorPolicyDraft) return;
|
||||||
|
setGovernorPolicyError(null);
|
||||||
|
setGovernorPolicySaveSuccess(null);
|
||||||
|
saveGovernorPolicyMutation.mutate({
|
||||||
|
enabled: governorPolicyDraft.enabled,
|
||||||
|
run_interval_seconds:
|
||||||
|
governorPolicyDraft.run_interval_seconds,
|
||||||
|
ladder: governorPolicyDraft.ladder,
|
||||||
|
lead_cap_every: governorPolicyDraft.lead_cap_every,
|
||||||
|
activity_trigger_type:
|
||||||
|
governorPolicyDraft.activity_trigger_type,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
isLoading ||
|
||||||
|
saveGovernorPolicyMutation.isPending ||
|
||||||
|
!governorPolicyDraft.ladder.length ||
|
||||||
|
!governorPolicyDraft.lead_cap_every.trim()
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{saveGovernorPolicyMutation.isPending ? "Saving…" : "Save governor policy"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
|
Loading governor policy…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{gateways.length === 0 ? (
|
{gateways.length === 0 ? (
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
Reference in New Issue
Block a user