mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
Revert "fix(ui): prevent lossy cron rewrites + redesign routine triggers tab" (#5725)
## Thinking Path > - Paperclip orchestrates AI agents through visible, governable task and routine workflows. > - Routines are the recurring-work surface where operators configure schedules, runs, and activity. > - PR #3569 moved routine operational tabs into the right-hand properties panel while also redesigning the routine trigger editor. > - The current product request is to remove that routine properties right-tab change for now and come back to it later. > - The cleanest way to do that is a direct revert of #3569 on top of current `master`, which already includes the #5703 revert. > - This pull request restores the pre-#3569 routine trigger/detail behavior and removes the right-tab properties-panel routine layout. > - The benefit is a simple, reviewable rollback with no schema or API changes. ## What Changed - Reverted #3569: `fix(ui): prevent lossy cron rewrites + redesign routine triggers tab`. - Restored the previous `RoutineDetail` inline tabs and trigger editing flow. - Restored the earlier `ScheduleEditor` implementation. - Removed the UI components and tests introduced by #3569: `ConfirmDialog`, `TriggerDialog`, `TriggerListCard`, and `ScheduleEditor.test.ts`. ## Verification - `git diff --check origin/master..HEAD` - `pnpm vitest run ui/src/pages/Routines.test.tsx ui/src/components/RoutineHistoryTab.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` Notes: - `pnpm install --frozen-lockfile` was run in the clean worktree before verification. It completed with known workspace bin-link warnings for `paperclip-plugin-dev-server` because the plugin SDK `dist/dev-cli.js` has not been built in that fresh worktree. - `Routines.test.tsx` emitted existing Radix dialog accessibility warnings during the test run; the tests passed. ### Screenshots This is a direct revert of #3569. The visual state after this PR corresponds to the old screenshot from #3569, and the state being removed corresponds to the new/right-panel screenshots from #3569. | Before this revert | After this revert | | --- | --- | | <img width="1410" height="1325" alt="routine-triggers-before-this-revert" src="https://github.com/user-attachments/assets/d70dd35b-e72f-4fc6-bb21-be9b0d92b3b1" /> | <img width="721" height="707" alt="routine-triggers-after-this-revert" src="https://github.com/user-attachments/assets/260bb682-32cb-4dff-b038-d55e45824b04" /> | Right-hand properties panel state removed by this revert: <img width="1409" height="830" alt="routine-properties-panel-removed" src="https://github.com/user-attachments/assets/f1d42f07-7cd3-4614-8e93-5b585affd4bf" /> ## Risks - Low technical risk: this is a clean Git revert of a UI-only PR. - Product risk: #3569 also fixed lossy cron editing and added broader schedule presets, so this rollback intentionally removes those improvements along with the right-tab routine layout. - Follow-up risk: if we want only the schedule-editor fixes back later, they should be reintroduced separately from the routine properties-panel layout. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 coding agent, tool-enabled with local shell and GitHub CLI access. Context window size was not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
0c6f9bdcf8
commit
8af38fb054
6 changed files with 630 additions and 1901 deletions
|
|
@ -1,57 +0,0 @@
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
|
|
||||||
interface ConfirmDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
confirmLabel?: string;
|
|
||||||
cancelLabel?: string;
|
|
||||||
destructive?: boolean;
|
|
||||||
onConfirm: () => void;
|
|
||||||
busy?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConfirmDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
confirmLabel = "Confirm",
|
|
||||||
cancelLabel = "Cancel",
|
|
||||||
destructive,
|
|
||||||
onConfirm,
|
|
||||||
busy,
|
|
||||||
}: ConfirmDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-sm">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{title}</DialogTitle>
|
|
||||||
{description && <DialogDescription>{description}</DialogDescription>}
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)} disabled={busy}>
|
|
||||||
{cancelLabel}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={destructive ? "destructive" : "default"}
|
|
||||||
size="sm"
|
|
||||||
onClick={onConfirm}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
{busy ? "Working…" : confirmLabel}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,166 +0,0 @@
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
describeSchedule,
|
|
||||||
getScheduleEditorPresetForTest,
|
|
||||||
hasSingleMinuteAcrossTimesForTest,
|
|
||||||
parseCronToPreset,
|
|
||||||
roundTripCronForTest,
|
|
||||||
} from "./ScheduleEditor";
|
|
||||||
|
|
||||||
describe("parseCronToPreset", () => {
|
|
||||||
describe("simple single-value crons map to presets", () => {
|
|
||||||
it("maps `* * * * *` to every_minute", () => {
|
|
||||||
expect(parseCronToPreset("* * * * *").preset).toBe("every_minute");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps `0 * * * *` to every_hour", () => {
|
|
||||||
const parsed = parseCronToPreset("0 * * * *");
|
|
||||||
expect(parsed.preset).toBe("every_hour");
|
|
||||||
expect(parsed.minute).toBe("0");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps `0 9 * * *` to every_day at 09:00", () => {
|
|
||||||
const parsed = parseCronToPreset("0 9 * * *");
|
|
||||||
expect(parsed.preset).toBe("every_day");
|
|
||||||
expect(parsed.hour).toBe("9");
|
|
||||||
expect(parsed.minute).toBe("0");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps `0 9 * * 1-5` to weekdays", () => {
|
|
||||||
const parsed = parseCronToPreset("0 9 * * 1-5");
|
|
||||||
expect(parsed.preset).toBe("weekdays");
|
|
||||||
expect(parsed.hour).toBe("9");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps `0 9 * * 1` to weekly on Monday", () => {
|
|
||||||
const parsed = parseCronToPreset("0 9 * * 1");
|
|
||||||
expect(parsed.preset).toBe("weekly");
|
|
||||||
expect(parsed.dayOfWeek).toBe("1");
|
|
||||||
expect(parsed.hour).toBe("9");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps `0 9 1 * *` to monthly on the 1st", () => {
|
|
||||||
const parsed = parseCronToPreset("0 9 1 * *");
|
|
||||||
expect(parsed.preset).toBe("monthly");
|
|
||||||
expect(parsed.dayOfMonth).toBe("1");
|
|
||||||
expect(parsed.hour).toBe("9");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("complex crons round-trip via custom preset (regression: comma lists were silently coerced into every_day)", () => {
|
|
||||||
it("routes comma-separated hours to custom", () => {
|
|
||||||
// Regression: `0 9,13,17 * * *` used to be parsed as `every_day` with
|
|
||||||
// hour `"9,13,17"`, which the hour <Select> couldn't render. Saving the
|
|
||||||
// form then rebuilt the cron as `0 10 * * *`, silently collapsing three
|
|
||||||
// daily fires into one.
|
|
||||||
expect(parseCronToPreset("0 9,13,17 * * *").preset).toBe("custom");
|
|
||||||
expect(parseCronToPreset("0 10,16 * * *").preset).toBe("custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes step expressions to custom", () => {
|
|
||||||
expect(parseCronToPreset("0 */4 * * *").preset).toBe("custom");
|
|
||||||
expect(parseCronToPreset("*/15 * * * *").preset).toBe("custom");
|
|
||||||
expect(parseCronToPreset("0 9-17/2 * * *").preset).toBe("custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes range expressions (other than weekday 1-5) to custom", () => {
|
|
||||||
expect(parseCronToPreset("0 9-17 * * *").preset).toBe("custom");
|
|
||||||
expect(parseCronToPreset("15-45 * * * *").preset).toBe("custom");
|
|
||||||
expect(parseCronToPreset("0 9 1-15 * *").preset).toBe("custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes comma-separated day-of-week to custom", () => {
|
|
||||||
expect(parseCronToPreset("0 9 * * 1,3,5").preset).toBe("custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes hourly-on-weekdays schedules to custom to preserve the stored expression", () => {
|
|
||||||
expect(getScheduleEditorPresetForTest("5 * * * 1-5")).toBe("custom");
|
|
||||||
expect(roundTripCronForTest("5 * * * 1-5")).toBe("5 * * * 1-5");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes non-wildcard month field to custom", () => {
|
|
||||||
// None of the presets encode a month, so even a single numeric month
|
|
||||||
// must fall through to custom to avoid being silently dropped.
|
|
||||||
expect(parseCronToPreset("0 9 1 1 *").preset).toBe("custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes unknown tokens to custom", () => {
|
|
||||||
expect(parseCronToPreset("0 MON * * *").preset).toBe("custom");
|
|
||||||
expect(parseCronToPreset("@daily").preset).toBe("custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("routes malformed crons to custom", () => {
|
|
||||||
expect(parseCronToPreset("not a cron").preset).toBe("custom");
|
|
||||||
expect(parseCronToPreset("0 9 *").preset).toBe("custom");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("describeSchedule", () => {
|
|
||||||
it("describes simple presets in plain English", () => {
|
|
||||||
expect(describeSchedule("0 9 * * *")).toContain("Every day");
|
|
||||||
expect(describeSchedule("0 9 * * 1-5")).toContain("weekday");
|
|
||||||
expect(describeSchedule("0 9 * * 1")).toContain("Monday");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("describes multi-value hour lists (these previously collapsed silently)", () => {
|
|
||||||
// Regression guard. Pre-fix, these crons round-tripped to "Every day at …"
|
|
||||||
// with a silently-wrong single hour. Post-fix they rendered as the raw
|
|
||||||
// cron string. Now that the editor can represent multi-value hour lists
|
|
||||||
// first-class, describeSchedule unfolds them into a readable sentence.
|
|
||||||
expect(describeSchedule("0 9,13,17 * * *")).toBe("Every day at 09:00, 13:00 and 17:00");
|
|
||||||
expect(describeSchedule("0 10,16 * * *")).toBe("Every day at 10:00 and 16:00");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("describes step expressions in plain English", () => {
|
|
||||||
expect(describeSchedule("0 */4 * * *")).toBe("Every 4 hours at :00");
|
|
||||||
expect(describeSchedule("*/15 * * * *")).toBe("Every 15 minutes");
|
|
||||||
expect(describeSchedule("*/15 9-17 * * 1-5")).toContain("between 09:00 and 17:00");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("describes multi-day weekday selections", () => {
|
|
||||||
expect(describeSchedule("0 9 * * 1,3,5")).toBe("Every Mon, Wed, Fri at 09:00");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("describes multi-date monthly selections with ordinals", () => {
|
|
||||||
expect(describeSchedule("0 9 1,15 * *")).toBe("On the 1st, 15th of the month at 09:00");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the raw cron string for expressions it can't confidently describe", () => {
|
|
||||||
// Named tokens and exotic forms still round-trip as the raw cron.
|
|
||||||
expect(describeSchedule("0 MON * * *")).toBe("0 MON * * *");
|
|
||||||
expect(describeSchedule("@daily")).toBe("@daily");
|
|
||||||
expect(describeSchedule("not a cron")).toBe("not a cron");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the default 10:00 preset for an empty cron", () => {
|
|
||||||
expect(describeSchedule("")).toBe("Every day at 10:00");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getScheduleEditorPresetForTest", () => {
|
|
||||||
it("keeps malformed hour fields in custom mode instead of coercing them into daily", () => {
|
|
||||||
expect(getScheduleEditorPresetForTest("0 foo * * *")).toBe("custom");
|
|
||||||
expect(getScheduleEditorPresetForTest("0 9-foo * * *")).toBe("custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps multi-minute daily schedules in custom mode because one trigger cannot represent arbitrary time pairs", () => {
|
|
||||||
expect(getScheduleEditorPresetForTest("15,45 9,13 * * *")).toBe("custom");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("roundTripCronForTest", () => {
|
|
||||||
it("preserves weekday ranges instead of normalizing them to a comma list", () => {
|
|
||||||
expect(roundTripCronForTest("0 9 * * 1-5")).toBe("0 9 * * 1-5");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("hasSingleMinuteAcrossTimesForTest", () => {
|
|
||||||
it("accepts same-minute multi-time selections", () => {
|
|
||||||
expect(hasSingleMinuteAcrossTimesForTest(["09:00", "13:00", "17:00"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects mixed-minute selections that would expand into extra cron runs", () => {
|
|
||||||
expect(hasSingleMinuteAcrossTimesForTest(["09:15", "13:45"])).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,268 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import type { RoutineTrigger } from "@paperclipai/shared";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
|
||||||
import { ScheduleEditor } from "./ScheduleEditor";
|
|
||||||
|
|
||||||
const triggerKinds = ["schedule", "webhook"] as const;
|
|
||||||
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"] as const;
|
|
||||||
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set<string>(["github_hmac", "none"]);
|
|
||||||
const signingModeDescriptions: Record<string, string> = {
|
|
||||||
bearer: "Expect a shared bearer token in the Authorization header.",
|
|
||||||
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
|
|
||||||
github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).",
|
|
||||||
none: "No authentication — the webhook URL itself acts as a shared secret.",
|
|
||||||
};
|
|
||||||
|
|
||||||
type TriggerKind = (typeof triggerKinds)[number];
|
|
||||||
|
|
||||||
export interface TriggerDialogState {
|
|
||||||
label: string;
|
|
||||||
kind: TriggerKind;
|
|
||||||
cronExpression: string;
|
|
||||||
signingMode: string;
|
|
||||||
replayWindowSec: string;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TriggerDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
/** When editing an existing trigger, pass it here. Null for create. */
|
|
||||||
trigger: RoutineTrigger | null;
|
|
||||||
/** Timezone to use when creating a new schedule trigger (the detail page uses the browser's zone). */
|
|
||||||
fallbackTimezone: string;
|
|
||||||
/** Called when the user submits. For updates `id` is non-null. */
|
|
||||||
onSubmit: (payload: {
|
|
||||||
id: string | null;
|
|
||||||
kind: TriggerKind;
|
|
||||||
// For create: full body. For update: partial patch ready to send.
|
|
||||||
body: Record<string, unknown>;
|
|
||||||
}) => void;
|
|
||||||
submitting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BLANK: TriggerDialogState = {
|
|
||||||
label: "",
|
|
||||||
kind: "schedule",
|
|
||||||
cronExpression: "0 9 * * 1-5",
|
|
||||||
signingMode: "bearer",
|
|
||||||
replayWindowSec: "300",
|
|
||||||
enabled: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
function draftFromTrigger(trigger: RoutineTrigger | null): TriggerDialogState {
|
|
||||||
if (!trigger) return { ...BLANK };
|
|
||||||
return {
|
|
||||||
label: trigger.label ?? "",
|
|
||||||
kind: (trigger.kind as TriggerKind) ?? "schedule",
|
|
||||||
cronExpression: trigger.cronExpression ?? "0 9 * * 1-5",
|
|
||||||
signingMode: trigger.signingMode ?? "bearer",
|
|
||||||
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
|
||||||
enabled: trigger.enabled,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseReplayWindowSec(raw: string): number {
|
|
||||||
const parsed = Number(raw);
|
|
||||||
if (!Number.isFinite(parsed) || parsed < 1) return 300;
|
|
||||||
return Math.trunc(parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TriggerDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
trigger,
|
|
||||||
fallbackTimezone,
|
|
||||||
onSubmit,
|
|
||||||
submitting,
|
|
||||||
}: TriggerDialogProps) {
|
|
||||||
const isEdit = !!trigger;
|
|
||||||
const [draft, setDraft] = useState<TriggerDialogState>(() => draftFromTrigger(trigger));
|
|
||||||
|
|
||||||
// Reset the draft whenever the dialog opens with a different trigger.
|
|
||||||
useEffect(() => {
|
|
||||||
if (open) setDraft(draftFromTrigger(trigger));
|
|
||||||
}, [open, trigger]);
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
const labelTrimmed = draft.label.trim();
|
|
||||||
|
|
||||||
if (isEdit && trigger) {
|
|
||||||
// Build a PATCH body. Match the fields the backend accepts on
|
|
||||||
// PATCH /routine-triggers/:id (see updateRoutineTriggerSchema).
|
|
||||||
const patch: Record<string, unknown> = {
|
|
||||||
label: labelTrimmed || null,
|
|
||||||
enabled: draft.enabled,
|
|
||||||
};
|
|
||||||
if (trigger.kind === "schedule") {
|
|
||||||
patch.cronExpression = draft.cronExpression.trim();
|
|
||||||
patch.timezone = trigger.timezone ?? fallbackTimezone;
|
|
||||||
}
|
|
||||||
if (trigger.kind === "webhook") {
|
|
||||||
patch.signingMode = draft.signingMode;
|
|
||||||
patch.replayWindowSec = parseReplayWindowSec(draft.replayWindowSec);
|
|
||||||
}
|
|
||||||
onSubmit({ id: trigger.id, kind: trigger.kind as TriggerKind, body: patch });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create body: match POST /routines/:id/triggers (createRoutineTriggerSchema).
|
|
||||||
const body: Record<string, unknown> = {
|
|
||||||
kind: draft.kind,
|
|
||||||
label: labelTrimmed || draft.kind,
|
|
||||||
};
|
|
||||||
if (draft.kind === "schedule") {
|
|
||||||
body.cronExpression = draft.cronExpression.trim();
|
|
||||||
body.timezone = fallbackTimezone;
|
|
||||||
}
|
|
||||||
if (draft.kind === "webhook") {
|
|
||||||
body.signingMode = draft.signingMode;
|
|
||||||
body.replayWindowSec = parseReplayWindowSec(draft.replayWindowSec);
|
|
||||||
}
|
|
||||||
onSubmit({ id: null, kind: draft.kind, body });
|
|
||||||
};
|
|
||||||
|
|
||||||
const showWebhookFields = draft.kind === "webhook";
|
|
||||||
const showScheduleFields = draft.kind === "schedule";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>{isEdit ? "Edit trigger" : "Add trigger"}</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Configure when and how this routine fires.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-5 pt-1">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="trigger-label" className="text-xs">Label</Label>
|
|
||||||
<Input
|
|
||||||
id="trigger-label"
|
|
||||||
placeholder="e.g. Morning digest"
|
|
||||||
value={draft.label}
|
|
||||||
onChange={(e) => setDraft((d) => ({ ...d, label: e.target.value }))}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Optional — shown in the trigger list.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Kind</Label>
|
|
||||||
<Select
|
|
||||||
value={draft.kind}
|
|
||||||
onValueChange={(kind) => setDraft((d) => ({ ...d, kind: kind as TriggerKind }))}
|
|
||||||
disabled={isEdit}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{triggerKinds.map((kind) => (
|
|
||||||
<SelectItem
|
|
||||||
key={kind}
|
|
||||||
value={kind}
|
|
||||||
disabled={!isEdit && kind === "webhook"}
|
|
||||||
>
|
|
||||||
{kind}
|
|
||||||
{!isEdit && kind === "webhook" ? " — COMING SOON" : ""}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
{isEdit && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Kind can't be changed after creation.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showScheduleFields && (
|
|
||||||
<ScheduleEditor
|
|
||||||
value={draft.cronExpression}
|
|
||||||
onChange={(cronExpression) => setDraft((d) => ({ ...d, cronExpression }))}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showWebhookFields && (
|
|
||||||
<div className="grid gap-3 md:grid-cols-2">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Signing mode</Label>
|
|
||||||
<Select
|
|
||||||
value={draft.signingMode}
|
|
||||||
onValueChange={(signingMode) => setDraft((d) => ({ ...d, signingMode }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{signingModes.map((mode) => (
|
|
||||||
<SelectItem key={mode} value={mode}>
|
|
||||||
{mode}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{signingModeDescriptions[draft.signingMode]}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label className="text-xs">Replay window (seconds)</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
step={1}
|
|
||||||
value={draft.replayWindowSec}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((d) => ({ ...d, replayWindowSec: e.target.value }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="mt-6">
|
|
||||||
{isEdit && (
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer text-sm mr-auto">
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={draft.enabled}
|
|
||||||
onCheckedChange={(enabled) => setDraft((d) => ({ ...d, enabled }))}
|
|
||||||
/>
|
|
||||||
<span>{draft.enabled ? "Enabled" : "Paused"}</span>
|
|
||||||
</label>
|
|
||||||
)}
|
|
||||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" onClick={handleSubmit} disabled={submitting}>
|
|
||||||
{submitting ? "Saving…" : isEdit ? "Save changes" : "Add trigger"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
import { Clock3, Pencil, RefreshCw, Trash2, Webhook, Zap } from "lucide-react";
|
|
||||||
import type { RoutineTrigger } from "@paperclipai/shared";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
|
||||||
import { describeSchedule } from "./ScheduleEditor";
|
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
|
||||||
|
|
||||||
interface TriggerListCardProps {
|
|
||||||
trigger: RoutineTrigger;
|
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
|
||||||
onToggleEnabled: (enabled: boolean) => void;
|
|
||||||
onRotateSecret?: () => void;
|
|
||||||
togglePending?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TriggerListCard({
|
|
||||||
trigger,
|
|
||||||
onEdit,
|
|
||||||
onDelete,
|
|
||||||
onToggleEnabled,
|
|
||||||
onRotateSecret,
|
|
||||||
togglePending,
|
|
||||||
}: TriggerListCardProps) {
|
|
||||||
const isSchedule = trigger.kind === "schedule";
|
|
||||||
const isWebhook = trigger.kind === "webhook";
|
|
||||||
const Icon = isSchedule ? Clock3 : isWebhook ? Webhook : Zap;
|
|
||||||
|
|
||||||
const summary = isSchedule && trigger.cronExpression
|
|
||||||
? describeSchedule(trigger.cronExpression)
|
|
||||||
: isWebhook
|
|
||||||
? `Webhook${trigger.publicId ? ` · ${trigger.publicId}` : ""}`
|
|
||||||
: "API trigger";
|
|
||||||
|
|
||||||
const nextRun = isSchedule && trigger.enabled && trigger.nextRunAt
|
|
||||||
? new Date(trigger.nextRunAt).toLocaleString(undefined, {
|
|
||||||
weekday: "short",
|
|
||||||
day: "numeric",
|
|
||||||
month: "short",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})
|
|
||||||
: trigger.enabled ? "—" : "Disabled";
|
|
||||||
|
|
||||||
const lastFired = trigger.lastFiredAt ? timeAgo(trigger.lastFiredAt) : "Never";
|
|
||||||
|
|
||||||
const resultIsError = typeof trigger.lastResult === "string" && /error|fail/i.test(trigger.lastResult);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`rounded-lg border border-border p-3 transition-colors ${trigger.enabled ? "bg-card" : "bg-muted/40"}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
|
||||||
<Icon className="h-3.5 w-3.5" />
|
|
||||||
</div>
|
|
||||||
<span className={`text-sm font-medium truncate flex-1 min-w-0 ${trigger.enabled ? "" : "text-muted-foreground"}`}>
|
|
||||||
{trigger.label || (isSchedule ? "Schedule" : isWebhook ? "Webhook" : "Trigger")}
|
|
||||||
</span>
|
|
||||||
<ToggleSwitch
|
|
||||||
checked={trigger.enabled}
|
|
||||||
onCheckedChange={onToggleEnabled}
|
|
||||||
disabled={togglePending}
|
|
||||||
aria-label={trigger.enabled ? "Disable trigger" : "Enable trigger"}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap mt-2">
|
|
||||||
<Badge variant="outline" className="text-[11px]">
|
|
||||||
{trigger.kind}
|
|
||||||
</Badge>
|
|
||||||
{!trigger.enabled && (
|
|
||||||
<Badge variant="secondary" className="text-[11px] text-muted-foreground">
|
|
||||||
paused
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2 text-sm break-words">{summary}</div>
|
|
||||||
{isSchedule && trigger.cronExpression && (
|
|
||||||
<div className="text-xs text-muted-foreground mt-1 font-mono break-all">
|
|
||||||
{trigger.cronExpression}
|
|
||||||
{trigger.timezone ? ` · ${trigger.timezone}` : ""}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<dl className="mt-3 space-y-2 text-xs">
|
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
|
||||||
<dt className="text-muted-foreground">Next run</dt>
|
|
||||||
<dd className="break-words">{nextRun}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
|
||||||
<dt className="text-muted-foreground">Last fired</dt>
|
|
||||||
<dd className="break-words">{lastFired}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0.5 min-w-0">
|
|
||||||
<dt className="text-muted-foreground">Last result</dt>
|
|
||||||
<dd className="min-w-0">
|
|
||||||
{trigger.lastResult ? (
|
|
||||||
<span
|
|
||||||
className={`inline-block rounded px-1.5 py-0.5 text-[11px] break-words ${
|
|
||||||
resultIsError
|
|
||||||
? "bg-destructive/15 text-destructive"
|
|
||||||
: "bg-secondary text-secondary-foreground"
|
|
||||||
}`}
|
|
||||||
title={trigger.lastResult}
|
|
||||||
>
|
|
||||||
{trigger.lastResult}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-end gap-1 border-t border-border pt-2">
|
|
||||||
{isWebhook && onRotateSecret && (
|
|
||||||
<Button variant="ghost" size="xs" onClick={onRotateSecret} title="Rotate secret">
|
|
||||||
<RefreshCw className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="ghost" size="xs" onClick={onEdit} title="Edit">
|
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="xs"
|
|
||||||
onClick={onDelete}
|
|
||||||
title="Delete"
|
|
||||||
className="text-muted-foreground hover:text-destructive"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,16 +9,15 @@ import {
|
||||||
Copy,
|
Copy,
|
||||||
History as HistoryIcon,
|
History as HistoryIcon,
|
||||||
Play,
|
Play,
|
||||||
Plus,
|
RefreshCw,
|
||||||
Repeat,
|
Repeat,
|
||||||
Save,
|
Save,
|
||||||
SlidersHorizontal,
|
Trash2,
|
||||||
|
Webhook,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse, type RestoreRoutineRevisionResponse } from "../api/routines";
|
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse, type RestoreRoutineRevisionResponse } from "../api/routines";
|
||||||
import { TriggerListCard } from "../components/TriggerListCard";
|
|
||||||
import { TriggerDialog } from "../components/TriggerDialog";
|
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
|
||||||
import {
|
import {
|
||||||
RoutineHistoryTab,
|
RoutineHistoryTab,
|
||||||
type RoutineHistoryDirtyFieldDescriptor,
|
type RoutineHistoryDirtyFieldDescriptor,
|
||||||
|
|
@ -30,10 +29,9 @@ import { projectsApi } from "../api/projects";
|
||||||
import { accessApi } from "../api/access";
|
import { accessApi } from "../api/access";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { usePanel } from "../context/PanelContext";
|
|
||||||
import { useToastActions } from "../context/ToastContext";
|
import { useToastActions } from "../context/ToastContext";
|
||||||
import { cn } from "../lib/utils";
|
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { buildRoutineTriggerPatch } from "../lib/routine-trigger-patch";
|
||||||
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
import { buildMarkdownMentionOptions } from "../lib/company-members";
|
||||||
import { timeAgo } from "../lib/timeAgo";
|
import { timeAgo } from "../lib/timeAgo";
|
||||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||||
|
|
@ -47,6 +45,7 @@ import {
|
||||||
type RoutineRunDialogSubmitData,
|
type RoutineRunDialogSubmitData,
|
||||||
} from "../components/RoutineRunVariablesDialog";
|
} from "../components/RoutineRunVariablesDialog";
|
||||||
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
|
import { RoutineVariablesEditor, RoutineVariablesHint } from "../components/RoutineVariablesEditor";
|
||||||
|
import { ScheduleEditor, describeSchedule } from "../components/ScheduleEditor";
|
||||||
import { RunButton } from "../components/AgentActionButtons";
|
import { RunButton } from "../components/AgentActionButtons";
|
||||||
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
import { getRecentAssigneeIds, sortAgentsByRecency, trackRecentAssignee } from "../lib/recent-assignees";
|
||||||
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
import { getRecentProjectIds, trackRecentProject } from "../lib/recent-projects";
|
||||||
|
|
@ -68,6 +67,8 @@ import type { RoutineDetail as RoutineDetailType, RoutineTrigger, RoutineVariabl
|
||||||
|
|
||||||
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
|
||||||
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
|
||||||
|
const triggerKinds = ["schedule", "webhook"];
|
||||||
|
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
|
||||||
const routineTabs = ["triggers", "runs", "activity", "history"] as const;
|
const routineTabs = ["triggers", "runs", "activity", "history"] as const;
|
||||||
const concurrencyPolicyDescriptions: Record<string, string> = {
|
const concurrencyPolicyDescriptions: Record<string, string> = {
|
||||||
coalesce_if_active: "Keep one follow-up run queued while an active run is still working.",
|
coalesce_if_active: "Keep one follow-up run queued while an active run is still working.",
|
||||||
|
|
@ -78,6 +79,13 @@ const catchUpPolicyDescriptions: Record<string, string> = {
|
||||||
skip_missed: "Ignore schedule windows that were missed while the routine or scheduler was paused.",
|
skip_missed: "Ignore schedule windows that were missed while the routine or scheduler was paused.",
|
||||||
enqueue_missed_with_cap: "Catch up missed schedule windows in capped batches after recovery.",
|
enqueue_missed_with_cap: "Catch up missed schedule windows in capped batches after recovery.",
|
||||||
};
|
};
|
||||||
|
const signingModeDescriptions: Record<string, string> = {
|
||||||
|
bearer: "Expect a shared bearer token in the Authorization header.",
|
||||||
|
hmac_sha256: "Expect an HMAC SHA-256 signature over the request using the shared secret.",
|
||||||
|
github_hmac: "Accept GitHub-style X-Hub-Signature-256 header (HMAC over raw body, no timestamp).",
|
||||||
|
none: "No authentication — the webhook URL itself acts as a shared secret.",
|
||||||
|
};
|
||||||
|
const SIGNING_MODES_WITHOUT_REPLAY_WINDOW = new Set(["github_hmac", "none"]);
|
||||||
|
|
||||||
type RoutineTab = (typeof routineTabs)[number];
|
type RoutineTab = (typeof routineTabs)[number];
|
||||||
|
|
||||||
|
|
@ -142,6 +150,128 @@ function buildRoutineMutationPayload(input: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TriggerEditor({
|
||||||
|
trigger,
|
||||||
|
onSave,
|
||||||
|
onRotate,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
trigger: RoutineTrigger;
|
||||||
|
onSave: (id: string, patch: Record<string, unknown>) => void;
|
||||||
|
onRotate: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const [draft, setDraft] = useState({
|
||||||
|
label: trigger.label ?? "",
|
||||||
|
cronExpression: trigger.cronExpression ?? "",
|
||||||
|
signingMode: trigger.signingMode ?? "bearer",
|
||||||
|
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDraft({
|
||||||
|
label: trigger.label ?? "",
|
||||||
|
cronExpression: trigger.cronExpression ?? "",
|
||||||
|
signingMode: trigger.signingMode ?? "bearer",
|
||||||
|
replayWindowSec: String(trigger.replayWindowSec ?? 300),
|
||||||
|
});
|
||||||
|
}, [trigger]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium">
|
||||||
|
{trigger.kind === "schedule" ? <Clock3 className="h-3.5 w-3.5" /> : trigger.kind === "webhook" ? <Webhook className="h-3.5 w-3.5" /> : <Zap className="h-3.5 w-3.5" />}
|
||||||
|
{trigger.label ?? trigger.kind}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{trigger.kind === "schedule" && trigger.nextRunAt
|
||||||
|
? `Next: ${new Date(trigger.nextRunAt).toLocaleString()}`
|
||||||
|
: trigger.kind === "webhook"
|
||||||
|
? "Webhook"
|
||||||
|
: "API"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Label</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.label}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, label: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{trigger.kind === "schedule" && (
|
||||||
|
<div className="md:col-span-2 space-y-1.5">
|
||||||
|
<Label className="text-xs">Schedule</Label>
|
||||||
|
<ScheduleEditor
|
||||||
|
value={draft.cronExpression}
|
||||||
|
onChange={(cronExpression) => setDraft((current) => ({ ...current, cronExpression }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{trigger.kind === "webhook" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Signing mode</Label>
|
||||||
|
<Select
|
||||||
|
value={draft.signingMode}
|
||||||
|
onValueChange={(signingMode) => setDraft((current) => ({ ...current, signingMode }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{signingModes.map((mode) => (
|
||||||
|
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(draft.signingMode) && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Replay window (seconds)</Label>
|
||||||
|
<Input
|
||||||
|
value={draft.replayWindowSec}
|
||||||
|
onChange={(event) => setDraft((current) => ({ ...current, replayWindowSec: event.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{trigger.lastResult && <span className="text-xs text-muted-foreground">Last: {trigger.lastResult}</span>}
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{trigger.kind === "webhook" && (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => onRotate(trigger.id)}>
|
||||||
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Rotate secret
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSave(trigger.id, buildRoutineTriggerPatch(trigger, draft, getLocalTimezone()))}
|
||||||
|
>
|
||||||
|
<Save className="mr-1.5 h-3.5 w-3.5" />
|
||||||
|
Save trigger
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-muted-foreground hover:text-destructive"
|
||||||
|
onClick={() => onDelete(trigger.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function RoutineDetail() {
|
export function RoutineDetail() {
|
||||||
const { routineId } = useParams<{ routineId: string }>();
|
const { routineId } = useParams<{ routineId: string }>();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
|
|
@ -150,7 +280,6 @@ export function RoutineDetail() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { pushToast } = useToastActions();
|
const { pushToast } = useToastActions();
|
||||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
|
||||||
const hydratedRoutineIdRef = useRef<string | null>(null);
|
const hydratedRoutineIdRef = useRef<string | null>(null);
|
||||||
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
const titleInputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
|
||||||
|
|
@ -160,10 +289,12 @@ export function RoutineDetail() {
|
||||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||||
const [saveConflict, setSaveConflict] = useState(false);
|
const [saveConflict, setSaveConflict] = useState(false);
|
||||||
const [runVariablesOpen, setRunVariablesOpen] = useState(false);
|
const [runVariablesOpen, setRunVariablesOpen] = useState(false);
|
||||||
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false);
|
const [newTrigger, setNewTrigger] = useState({
|
||||||
const [editingTrigger, setEditingTrigger] = useState<RoutineTrigger | null>(null);
|
kind: "schedule",
|
||||||
const [triggerPendingDelete, setTriggerPendingDelete] = useState<RoutineTrigger | null>(null);
|
cronExpression: "0 10 * * *",
|
||||||
const [togglingTriggerId, setTogglingTriggerId] = useState<string | null>(null);
|
signingMode: "bearer",
|
||||||
|
replayWindowSec: "300",
|
||||||
|
});
|
||||||
const [editDraft, setEditDraft] = useState<{
|
const [editDraft, setEditDraft] = useState<{
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|
@ -310,7 +441,7 @@ export function RoutineDetail() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setActiveTab = useCallback((value: string) => {
|
const setActiveTab = (value: string) => {
|
||||||
if (!routineId || !isRoutineTab(value)) return;
|
if (!routineId || !isRoutineTab(value)) return;
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
if (value === "triggers") {
|
if (value === "triggers") {
|
||||||
|
|
@ -326,7 +457,7 @@ export function RoutineDetail() {
|
||||||
},
|
},
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
);
|
);
|
||||||
}, [location.pathname, location.search, navigate, routineId]);
|
};
|
||||||
|
|
||||||
const saveRoutine = useMutation({
|
const saveRoutine = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => {
|
||||||
|
|
@ -363,11 +494,6 @@ export function RoutineDetail() {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const saveRoutineRef = useRef(saveRoutine);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
saveRoutineRef.current = saveRoutine;
|
|
||||||
}, [saveRoutine]);
|
|
||||||
|
|
||||||
const runRoutine = useMutation({
|
const runRoutine = useMutation({
|
||||||
mutationFn: (data?: RoutineRunDialogSubmitData) =>
|
mutationFn: (data?: RoutineRunDialogSubmitData) =>
|
||||||
|
|
@ -426,23 +552,24 @@ export function RoutineDetail() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const createTrigger = useMutation({
|
const createTrigger = useMutation({
|
||||||
mutationFn: async (body: Record<string, unknown>): Promise<RoutineTriggerResponse> => {
|
mutationFn: async (): Promise<RoutineTriggerResponse> => {
|
||||||
// Auto-label when the caller didn't provide one (e.g. dialog left the
|
const existingOfKind = (routine?.triggers ?? []).filter((t) => t.kind === newTrigger.kind).length;
|
||||||
// Label field blank). Keeps the existing "schedule-2"-style numbering
|
const autoLabel = existingOfKind > 0 ? `${newTrigger.kind}-${existingOfKind + 1}` : newTrigger.kind;
|
||||||
// behaviour so existing routines keep unique-ish labels.
|
return routinesApi.createTrigger(routineId!, {
|
||||||
const kind = String(body.kind ?? "schedule");
|
kind: newTrigger.kind,
|
||||||
const trimmedLabel = typeof body.label === "string" ? body.label.trim() : "";
|
label: autoLabel,
|
||||||
let finalLabel: string;
|
...(newTrigger.kind === "schedule"
|
||||||
if (trimmedLabel.length > 0 && trimmedLabel !== kind) {
|
? { cronExpression: newTrigger.cronExpression.trim(), timezone: getLocalTimezone() }
|
||||||
finalLabel = trimmedLabel;
|
: {}),
|
||||||
} else {
|
...(newTrigger.kind === "webhook"
|
||||||
const existingOfKind = (routine?.triggers ?? []).filter((t) => t.kind === kind).length;
|
? {
|
||||||
finalLabel = existingOfKind > 0 ? `${kind}-${existingOfKind + 1}` : kind;
|
signingMode: newTrigger.signingMode,
|
||||||
}
|
replayWindowSec: Number(newTrigger.replayWindowSec || "300"),
|
||||||
return routinesApi.createTrigger(routineId!, { ...body, label: finalLabel });
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
setTriggerDialogOpen(false);
|
|
||||||
if (result.secretMaterial) {
|
if (result.secretMaterial) {
|
||||||
setSecretMessage({
|
setSecretMessage({
|
||||||
title: "Webhook trigger created",
|
title: "Webhook trigger created",
|
||||||
|
|
@ -478,10 +605,9 @@ export function RoutineDetail() {
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
pushToast({
|
pushToast({
|
||||||
title: "Trigger saved",
|
title: "Trigger saved",
|
||||||
|
body: "The routine cadence update was saved.",
|
||||||
tone: "success",
|
tone: "success",
|
||||||
});
|
});
|
||||||
setTriggerDialogOpen(false);
|
|
||||||
setEditingTrigger(null);
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||||
|
|
@ -495,9 +621,6 @@ export function RoutineDetail() {
|
||||||
tone: "error",
|
tone: "error",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSettled: () => {
|
|
||||||
setTogglingTriggerId(null);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteTrigger = useMutation({
|
const deleteTrigger = useMutation({
|
||||||
|
|
@ -507,7 +630,6 @@ export function RoutineDetail() {
|
||||||
title: "Trigger deleted",
|
title: "Trigger deleted",
|
||||||
tone: "success",
|
tone: "success",
|
||||||
});
|
});
|
||||||
setTriggerPendingDelete(null);
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.detail(routineId!) }),
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
queryClient.invalidateQueries({ queryKey: queryKeys.routines.list(selectedCompanyId!) }),
|
||||||
|
|
@ -588,237 +710,6 @@ export function RoutineDetail() {
|
||||||
const currentAssignee = editDraft.assigneeAgentId ? agentById.get(editDraft.assigneeAgentId) ?? null : null;
|
const currentAssignee = editDraft.assigneeAgentId ? agentById.get(editDraft.assigneeAgentId) ?? null : null;
|
||||||
const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null;
|
const currentProject = editDraft.projectId ? projectById.get(editDraft.projectId) ?? null : null;
|
||||||
|
|
||||||
const activityTabsPanel = useMemo(() => {
|
|
||||||
if (!routine) return null;
|
|
||||||
return (
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-3">
|
|
||||||
<TabsList variant="line" className="w-full justify-start gap-1">
|
|
||||||
<TabsTrigger value="triggers" className="gap-1.5">
|
|
||||||
<Clock3 className="h-3.5 w-3.5" />
|
|
||||||
Triggers
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="runs" className="gap-1.5">
|
|
||||||
<Play className="h-3.5 w-3.5" />
|
|
||||||
Runs
|
|
||||||
{hasLiveRun && <span className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="activity" className="gap-1.5">
|
|
||||||
<ActivityIcon className="h-3.5 w-3.5" />
|
|
||||||
Activity
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="history" className="gap-1.5">
|
|
||||||
<HistoryIcon className="h-3.5 w-3.5" />
|
|
||||||
History
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
|
|
||||||
<TabsContent value="triggers" className="space-y-4">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingTrigger(null);
|
|
||||||
setTriggerDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Add trigger
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{routine.triggers.length === 0 ? (
|
|
||||||
<div className="rounded-lg border border-dashed border-border bg-muted/30 p-8 text-center">
|
|
||||||
<p className="text-sm font-medium">No triggers yet</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 mb-4">
|
|
||||||
Triggers fire this routine on a schedule or via webhook.
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setEditingTrigger(null);
|
|
||||||
setTriggerDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
||||||
Add your first trigger
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{routine.triggers.map((trigger) => (
|
|
||||||
<TriggerListCard
|
|
||||||
key={trigger.id}
|
|
||||||
trigger={trigger}
|
|
||||||
onEdit={() => {
|
|
||||||
setEditingTrigger(trigger);
|
|
||||||
setTriggerDialogOpen(true);
|
|
||||||
}}
|
|
||||||
onDelete={() => setTriggerPendingDelete(trigger)}
|
|
||||||
onToggleEnabled={(enabled) => {
|
|
||||||
setTogglingTriggerId(trigger.id);
|
|
||||||
updateTrigger.mutate({ id: trigger.id, patch: { enabled } });
|
|
||||||
}}
|
|
||||||
onRotateSecret={
|
|
||||||
trigger.kind === "webhook"
|
|
||||||
? () => rotateTrigger.mutate(trigger.id)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
togglePending={togglingTriggerId === trigger.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="runs" className="space-y-4">
|
|
||||||
{hasLiveRun && activeIssueId && routine && (
|
|
||||||
<LiveRunWidget issueId={activeIssueId} companyId={routine.companyId} />
|
|
||||||
)}
|
|
||||||
{(routineRuns ?? []).length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground">No runs yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="border border-border rounded-lg divide-y divide-border">
|
|
||||||
{(routineRuns ?? []).map((run) => (
|
|
||||||
<div key={run.id} className="flex flex-col gap-1.5 px-3 py-2 text-sm min-w-0">
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap">
|
|
||||||
<Badge variant="outline" className="text-[11px]">{run.source}</Badge>
|
|
||||||
<Badge variant={run.status === "failed" ? "destructive" : "secondary"} className="text-[11px]">
|
|
||||||
{run.status.replaceAll("_", " ")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{(run.trigger || run.linkedIssue) && (
|
|
||||||
<div className="flex items-center gap-1.5 flex-wrap text-xs min-w-0">
|
|
||||||
{run.trigger && (
|
|
||||||
<span className="text-muted-foreground truncate">{run.trigger.label ?? run.trigger.kind}</span>
|
|
||||||
)}
|
|
||||||
{run.linkedIssue && (
|
|
||||||
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className="text-muted-foreground hover:underline truncate">
|
|
||||||
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-[11px] text-muted-foreground">{timeAgo(run.triggeredAt)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="activity">
|
|
||||||
{(activity ?? []).length === 0 ? (
|
|
||||||
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
|
||||||
) : (
|
|
||||||
<div className="border border-border rounded-lg divide-y divide-border">
|
|
||||||
{(activity ?? []).map((event) => (
|
|
||||||
<div key={event.id} className="flex flex-col gap-1 px-3 py-2 text-xs min-w-0">
|
|
||||||
<span className="font-medium text-foreground/90">{event.action.replaceAll(".", " ")}</span>
|
|
||||||
{event.details && Object.keys(event.details).length > 0 && (
|
|
||||||
<div className="text-muted-foreground break-words">
|
|
||||||
{Object.entries(event.details).slice(0, 3).map(([key, value], i) => (
|
|
||||||
<span key={key}>
|
|
||||||
{i > 0 && <span className="mx-1 text-border">·</span>}
|
|
||||||
<span className="text-muted-foreground/70">{key.replaceAll("_", " ")}:</span>{" "}
|
|
||||||
{formatActivityDetailValue(value)}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span className="text-muted-foreground/60">{timeAgo(event.createdAt)}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="history">
|
|
||||||
<RoutineHistoryTab
|
|
||||||
routine={routine}
|
|
||||||
isEditDirty={isEditDirty}
|
|
||||||
dirtyFields={dirtyFields}
|
|
||||||
onDiscardEdits={() => {
|
|
||||||
if (routineDefaults) setEditDraft(routineDefaults);
|
|
||||||
}}
|
|
||||||
onSaveEdits={() => {
|
|
||||||
const currentSave = saveRoutineRef.current;
|
|
||||||
if (!currentSave.isPending && editDraft.title.trim()) {
|
|
||||||
currentSave.mutate();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
agents={agentById}
|
|
||||||
projects={projectById}
|
|
||||||
onRestoreSecretMaterials={(response: RestoreRoutineRevisionResponse) => {
|
|
||||||
if (response.secretMaterials.length > 0) {
|
|
||||||
setSecretMessage({
|
|
||||||
title: response.secretMaterials.length === 1
|
|
||||||
? "Webhook trigger restored"
|
|
||||||
: `${response.secretMaterials.length} webhook triggers restored`,
|
|
||||||
entries: response.secretMaterials.map((recreated) => ({
|
|
||||||
webhookUrl: recreated.webhookUrl,
|
|
||||||
webhookSecret: recreated.webhookSecret,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onRestored={(response: RestoreRoutineRevisionResponse) => {
|
|
||||||
setSaveConflict(false);
|
|
||||||
queryClient.setQueryData<RoutineDetailType | undefined>(
|
|
||||||
queryKeys.routines.detail(routineId!),
|
|
||||||
(prev) =>
|
|
||||||
prev
|
|
||||||
? {
|
|
||||||
...prev,
|
|
||||||
...response.routine,
|
|
||||||
latestRevisionId: response.revision.id,
|
|
||||||
latestRevisionNumber: response.revision.revisionNumber,
|
|
||||||
}
|
|
||||||
: prev,
|
|
||||||
);
|
|
||||||
setEditDraft({
|
|
||||||
title: response.routine.title,
|
|
||||||
description: response.routine.description ?? "",
|
|
||||||
projectId: response.routine.projectId ?? "",
|
|
||||||
assigneeAgentId: response.routine.assigneeAgentId ?? "",
|
|
||||||
priority: response.routine.priority,
|
|
||||||
concurrencyPolicy: response.routine.concurrencyPolicy,
|
|
||||||
catchUpPolicy: response.routine.catchUpPolicy,
|
|
||||||
variables: response.routine.variables,
|
|
||||||
});
|
|
||||||
hydratedRoutineIdRef.current = response.routine.id;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
activeIssueId,
|
|
||||||
activeTab,
|
|
||||||
activity,
|
|
||||||
agentById,
|
|
||||||
dirtyFields,
|
|
||||||
editDraft.title,
|
|
||||||
hasLiveRun,
|
|
||||||
isEditDirty,
|
|
||||||
projectById,
|
|
||||||
queryClient,
|
|
||||||
rotateTrigger.mutate,
|
|
||||||
routine,
|
|
||||||
routineDefaults,
|
|
||||||
routineRuns,
|
|
||||||
routineId,
|
|
||||||
setActiveTab,
|
|
||||||
togglingTriggerId,
|
|
||||||
updateTrigger.mutate,
|
|
||||||
]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!activityTabsPanel) {
|
|
||||||
closePanel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
openPanel(activityTabsPanel);
|
|
||||||
return () => closePanel();
|
|
||||||
}, [activityTabsPanel, closePanel, openPanel]);
|
|
||||||
|
|
||||||
if (!selectedCompanyId) {
|
if (!selectedCompanyId) {
|
||||||
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
return <EmptyState icon={Repeat} message="Select a company to view routines." />;
|
||||||
}
|
}
|
||||||
|
|
@ -920,18 +811,6 @@ export function RoutineDetail() {
|
||||||
<span className={`min-w-[3.75rem] text-sm font-medium ${automationLabelClassName}`}>
|
<span className={`min-w-[3.75rem] text-sm font-medium ${automationLabelClassName}`}>
|
||||||
{automationLabel}
|
{automationLabel}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon-xs"
|
|
||||||
className={cn(
|
|
||||||
"hidden md:inline-flex shrink-0 transition-opacity duration-200",
|
|
||||||
panelVisible ? "opacity-0 pointer-events-none w-0 overflow-hidden" : "opacity-100",
|
|
||||||
)}
|
|
||||||
onClick={() => setPanelVisible(true)}
|
|
||||||
title="Show triggers, runs and activity"
|
|
||||||
>
|
|
||||||
<SlidersHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1185,12 +1064,225 @@ export function RoutineDetail() {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="md:hidden" />
|
<Separator />
|
||||||
|
|
||||||
{/* Tabs (mobile only — desktop renders in the right properties panel) */}
|
{/* Tabs */}
|
||||||
<div className="md:hidden">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-3">
|
||||||
{activityTabsPanel}
|
<TabsList variant="line" className="w-full justify-start gap-1">
|
||||||
</div>
|
<TabsTrigger value="triggers" className="gap-1.5">
|
||||||
|
<Clock3 className="h-3.5 w-3.5" />
|
||||||
|
Triggers
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="runs" className="gap-1.5">
|
||||||
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
Runs
|
||||||
|
{hasLiveRun && <span className="h-2 w-2 rounded-full bg-blue-500 animate-pulse" />}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="activity" className="gap-1.5">
|
||||||
|
<ActivityIcon className="h-3.5 w-3.5" />
|
||||||
|
Activity
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="history" className="gap-1.5">
|
||||||
|
<HistoryIcon className="h-3.5 w-3.5" />
|
||||||
|
History
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="triggers" className="space-y-4">
|
||||||
|
{/* Add trigger form */}
|
||||||
|
<div className="rounded-lg border border-border p-4 space-y-3">
|
||||||
|
<p className="text-sm font-medium">Add trigger</p>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Kind</Label>
|
||||||
|
<Select value={newTrigger.kind} onValueChange={(kind) => setNewTrigger((current) => ({ ...current, kind }))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{triggerKinds.map((kind) => (
|
||||||
|
<SelectItem key={kind} value={kind} disabled={kind === "webhook"}>
|
||||||
|
{kind}{kind === "webhook" ? " — COMING SOON" : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{newTrigger.kind === "schedule" && (
|
||||||
|
<div className="md:col-span-2 space-y-1.5">
|
||||||
|
<Label className="text-xs">Schedule</Label>
|
||||||
|
<ScheduleEditor
|
||||||
|
value={newTrigger.cronExpression}
|
||||||
|
onChange={(cronExpression) => setNewTrigger((current) => ({ ...current, cronExpression }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{newTrigger.kind === "webhook" && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Signing mode</Label>
|
||||||
|
<Select value={newTrigger.signingMode} onValueChange={(signingMode) => setNewTrigger((current) => ({ ...current, signingMode }))}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{signingModes.map((mode) => (
|
||||||
|
<SelectItem key={mode} value={mode}>{mode}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">{signingModeDescriptions[newTrigger.signingMode]}</p>
|
||||||
|
</div>
|
||||||
|
{!SIGNING_MODES_WITHOUT_REPLAY_WINDOW.has(newTrigger.signingMode) && (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs">Replay window (seconds)</Label>
|
||||||
|
<Input value={newTrigger.replayWindowSec} onChange={(event) => setNewTrigger((current) => ({ ...current, replayWindowSec: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Button size="sm" onClick={() => createTrigger.mutate()} disabled={createTrigger.isPending}>
|
||||||
|
{createTrigger.isPending ? "Adding..." : "Add trigger"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing triggers */}
|
||||||
|
{routine.triggers.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No triggers configured yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{routine.triggers.map((trigger) => (
|
||||||
|
<TriggerEditor
|
||||||
|
key={trigger.id}
|
||||||
|
trigger={trigger}
|
||||||
|
onSave={(id, patch) => updateTrigger.mutate({ id, patch })}
|
||||||
|
onRotate={(id) => rotateTrigger.mutate(id)}
|
||||||
|
onDelete={(id) => deleteTrigger.mutate(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="runs" className="space-y-4">
|
||||||
|
{hasLiveRun && activeIssueId && routine && (
|
||||||
|
<LiveRunWidget issueId={activeIssueId} companyId={routine.companyId} />
|
||||||
|
)}
|
||||||
|
{(routineRuns ?? []).length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No runs yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="border border-border rounded-lg divide-y divide-border">
|
||||||
|
{(routineRuns ?? []).map((run) => (
|
||||||
|
<div key={run.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Badge variant="outline" className="shrink-0">{run.source}</Badge>
|
||||||
|
<Badge variant={run.status === "failed" ? "destructive" : "secondary"} className="shrink-0">
|
||||||
|
{run.status.replaceAll("_", " ")}
|
||||||
|
</Badge>
|
||||||
|
{run.trigger && (
|
||||||
|
<span className="text-muted-foreground truncate">{run.trigger.label ?? run.trigger.kind}</span>
|
||||||
|
)}
|
||||||
|
{run.linkedIssue && (
|
||||||
|
<Link to={`/issues/${run.linkedIssue.identifier ?? run.linkedIssue.id}`} className="text-muted-foreground hover:underline truncate">
|
||||||
|
{run.linkedIssue.identifier ?? run.linkedIssue.id.slice(0, 8)}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0 ml-2">{timeAgo(run.triggeredAt)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="activity">
|
||||||
|
{(activity ?? []).length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No activity yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="border border-border rounded-lg divide-y divide-border">
|
||||||
|
{(activity ?? []).map((event) => (
|
||||||
|
<div key={event.id} className="flex items-center justify-between px-3 py-2 text-xs gap-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="font-medium text-foreground/90 shrink-0">{event.action.replaceAll(".", " ")}</span>
|
||||||
|
{event.details && Object.keys(event.details).length > 0 && (
|
||||||
|
<span className="text-muted-foreground truncate">
|
||||||
|
{Object.entries(event.details).slice(0, 3).map(([key, value], i) => (
|
||||||
|
<span key={key}>
|
||||||
|
{i > 0 && <span className="mx-1 text-border">·</span>}
|
||||||
|
<span className="text-muted-foreground/70">{key.replaceAll("_", " ")}:</span>{" "}
|
||||||
|
{formatActivityDetailValue(value)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground/60 shrink-0">{timeAgo(event.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="history">
|
||||||
|
<RoutineHistoryTab
|
||||||
|
routine={routine}
|
||||||
|
isEditDirty={isEditDirty}
|
||||||
|
dirtyFields={dirtyFields}
|
||||||
|
onDiscardEdits={() => {
|
||||||
|
if (routineDefaults) setEditDraft(routineDefaults);
|
||||||
|
}}
|
||||||
|
onSaveEdits={() => {
|
||||||
|
if (!saveRoutine.isPending && editDraft.title.trim()) {
|
||||||
|
saveRoutine.mutate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
agents={agentById}
|
||||||
|
projects={projectById}
|
||||||
|
onRestoreSecretMaterials={(response: RestoreRoutineRevisionResponse) => {
|
||||||
|
if (response.secretMaterials.length > 0) {
|
||||||
|
setSecretMessage({
|
||||||
|
title: response.secretMaterials.length === 1
|
||||||
|
? "Webhook trigger restored"
|
||||||
|
: `${response.secretMaterials.length} webhook triggers restored`,
|
||||||
|
entries: response.secretMaterials.map((recreated) => ({
|
||||||
|
webhookUrl: recreated.webhookUrl,
|
||||||
|
webhookSecret: recreated.webhookSecret,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onRestored={(response: RestoreRoutineRevisionResponse) => {
|
||||||
|
setSaveConflict(false);
|
||||||
|
queryClient.setQueryData<RoutineDetailType | undefined>(
|
||||||
|
queryKeys.routines.detail(routineId!),
|
||||||
|
(prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
...response.routine,
|
||||||
|
latestRevisionId: response.revision.id,
|
||||||
|
latestRevisionNumber: response.revision.revisionNumber,
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
setEditDraft({
|
||||||
|
title: response.routine.title,
|
||||||
|
description: response.routine.description ?? "",
|
||||||
|
projectId: response.routine.projectId ?? "",
|
||||||
|
assigneeAgentId: response.routine.assigneeAgentId ?? "",
|
||||||
|
priority: response.routine.priority,
|
||||||
|
concurrencyPolicy: response.routine.concurrencyPolicy,
|
||||||
|
catchUpPolicy: response.routine.catchUpPolicy,
|
||||||
|
variables: response.routine.variables,
|
||||||
|
});
|
||||||
|
hydratedRoutineIdRef.current = response.routine.id;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
<RoutineRunVariablesDialog
|
<RoutineRunVariablesDialog
|
||||||
open={runVariablesOpen}
|
open={runVariablesOpen}
|
||||||
|
|
@ -1205,43 +1297,6 @@ export function RoutineDetail() {
|
||||||
isPending={runRoutine.isPending}
|
isPending={runRoutine.isPending}
|
||||||
onSubmit={(data) => runRoutine.mutate(data)}
|
onSubmit={(data) => runRoutine.mutate(data)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TriggerDialog
|
|
||||||
open={triggerDialogOpen}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
setTriggerDialogOpen(next);
|
|
||||||
if (!next) setEditingTrigger(null);
|
|
||||||
}}
|
|
||||||
trigger={editingTrigger}
|
|
||||||
fallbackTimezone={getLocalTimezone()}
|
|
||||||
submitting={createTrigger.isPending || updateTrigger.isPending}
|
|
||||||
onSubmit={({ id, body }) => {
|
|
||||||
if (id) {
|
|
||||||
updateTrigger.mutate({ id, patch: body });
|
|
||||||
} else {
|
|
||||||
createTrigger.mutate(body);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
open={!!triggerPendingDelete}
|
|
||||||
onOpenChange={(next) => {
|
|
||||||
if (!next) setTriggerPendingDelete(null);
|
|
||||||
}}
|
|
||||||
title="Delete trigger?"
|
|
||||||
description={
|
|
||||||
triggerPendingDelete
|
|
||||||
? `"${triggerPendingDelete.label ?? triggerPendingDelete.kind}" will be removed. This can't be undone.`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
confirmLabel="Delete"
|
|
||||||
destructive
|
|
||||||
busy={deleteTrigger.isPending}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (triggerPendingDelete) deleteTrigger.mutate(triggerPendingDelete.id);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue