Files
multica/packages/views/plugins/plugin-hook-activity.tsx
Jiayuan Zhangandmultica-agent 5bf10d485c MUL-6582: add durable scheduled Plugin hooks (#7448)
* MUL-6582 feat(plugins): add durable scheduled hooks

Co-authored-by: multica-agent <[email protected]>

* MUL-6582: add schedule-pulse example plugin

Ship a real scheduled plugin that records each unique delivery_id in
workspace storage, so a host retry does not look like a second pulse.
The cmd/server test installs this example manifest and drives two
scheduler replicas through a dropped first attempt.

Co-authored-by: multica-agent <[email protected]>

* MUL-6582 fix(migrations): make schedule indexes retry-safe

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: multica-agent <[email protected]>
2026-08-23 17:37:21 +08:00

137 lines
4.8 KiB
TypeScript

"use client";
import { useQuery } from "@tanstack/react-query";
import { AlertTriangle } from "lucide-react";
import { pluginInvocationsOptions } from "@multica/core/plugins";
import type { PluginHook, PluginInvocation } from "@multica/core/types";
import { cn } from "@multica/ui/lib/utils";
import { useLocale, useT } from "../i18n";
/**
* Why a plugin's hook is failing, where the admin who installed it will look.
*
* An `event` hook fails silently by design — nothing blocks, nobody is watching
* the call, and after a few failures the circuit breaker stops even trying. All
* of that is correct behaviour and completely invisible without this: the plugin
* still says "enabled" while doing nothing at all.
*
* Shows nothing when the recent calls all succeeded. A working plugin does not
* need a panel about it.
*/
/** A breaker trips on this many failures inside its window; see plugin_hook.go. */
const BREAKER_THRESHOLD = 5;
export function summarizeInvocations(invocations: readonly PluginInvocation[]) {
const recent = invocations.slice(0, 20);
const failures = recent.filter((invocation) => invocation.status !== "ok");
return {
total: recent.length,
failures,
// Consecutive from the newest end: a hook that failed and then recovered is
// not the same story as one that is failing right now.
consecutiveFailures: countLeadingFailures(recent),
lastError: failures[0]?.error,
};
}
function countLeadingFailures(invocations: readonly PluginInvocation[]): number {
let count = 0;
for (const invocation of invocations) {
if (invocation.status === "ok") break;
count += 1;
}
return count;
}
export function PluginHookActivity({ wsId, installationId }: { wsId: string; installationId: string }) {
const { t } = useT("settings");
const { data } = useQuery(pluginInvocationsOptions(wsId, installationId));
const summary = summarizeInvocations(data ?? []);
if (summary.failures.length === 0) return null;
const tripped = summary.consecutiveFailures >= BREAKER_THRESHOLD;
return (
<div
className={cn(
"flex items-start gap-2 rounded-md border px-3 py-2 text-caption",
// Red only once delivery has actually stopped. A plugin whose endpoint
// blipped twice is worth mentioning, not worth alarming about — and a
// banner that is always red stops being read.
tripped
? "border-destructive/40 bg-destructive/5 text-destructive"
: "border-surface-border text-muted-foreground",
)}
>
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<div className="min-w-0 space-y-0.5">
<div className="font-medium">
{tripped
? t(($) => $.plugins.hook_circuit_open)
: t(($) => $.plugins.hook_recent_failures, { count: summary.failures.length })}
</div>
{summary.lastError ? <div className="truncate">{summary.lastError}</div> : null}
</div>
</div>
);
}
export function PluginScheduleActivity({
wsId,
installationId,
hooks,
}: {
wsId: string;
installationId: string;
hooks: readonly PluginHook[];
}) {
const { t } = useT("settings");
const locale = useLocale();
const { data } = useQuery(pluginInvocationsOptions(wsId, installationId));
const scheduled = hooks.filter((hook) => hook.schedule !== undefined);
if (scheduled.length === 0) return null;
return (
<div className="space-y-1.5">
<div className="text-caption font-medium">{t(($) => $.plugins.schedule.activity_title)}</div>
<ul className="space-y-1">
{scheduled.map((hook) => {
const last = (data ?? []).find((invocation) => (
invocation.trigger === "schedule" && invocation.hook_key === hook.key
));
return (
<li key={hook.key} className="text-caption text-muted-foreground">
<span className="font-medium text-foreground">{hook.name}</span>
{last ? (
<span>
{" · "}
{t(($) => $.plugins.schedule.activity_last, {
status: last.status,
planned: formatInvocationTime(last.planned_at, locale),
actual: formatInvocationTime(last.created_at, locale),
attempt: last.attempt,
})}
</span>
) : (
<span>{" · "}{t(($) => $.plugins.schedule.activity_empty)}</span>
)}
</li>
);
})}
</ul>
</div>
);
}
function formatInvocationTime(value: string | undefined, locale: string): string {
if (!value) return "—";
const timestamp = Date.parse(value);
if (Number.isNaN(timestamp)) return "—";
return new Intl.DateTimeFormat(locale, {
dateStyle: "medium",
timeStyle: "short",
}).format(new Date(timestamp));
}