mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51:23 +00:00
feat: Function triggers — schedule, inbound hook, domain events
Migration 00016: function_id on scheduled_jobs/inbound_hooks, event_pattern on functions, 'event' in the run-trigger enum. - Jobs deliver to a function instead of HTTP when function_id is set; the run's status_code mirrors the process exit code. Clearing the field returns the job to URL delivery. - Inbound hooks gain mode "function" — received body becomes stdin payload, acked immediately, run recorded async. - event_pattern (document.*, *, exact type) fires the function detached on matching publishEvent — never blocks realtime/webhook fan-out. - One executeFunction path serves manual/schedule/hook/event; run rows record the trigger source. - Backend image now ships bun 1.3.14 so Functions work under compose. Dashboard: job target picker, hook function mode, event-pattern field. Verified live: job run → success (status_code 0), hook POST → run, document.created → event run with correct payload. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
c094afad65
commit
a84049b8fc
40 files changed
+716
-157
No files matched your search
@@ -1,9 +1,11 @@
|
||||
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js";
|
||||
import {
|
||||
AutomationService,
|
||||
FunctionsService,
|
||||
OpenAPI,
|
||||
type ScheduledJob,
|
||||
type ScheduledJobRun,
|
||||
type Function as PrimoraFunction,
|
||||
} from "@primora/api-client";
|
||||
import { fetchApiToken } from "../lib/auth-client";
|
||||
import { demoService } from "../lib/demo-mode";
|
||||
@@ -63,7 +65,8 @@ export function AutomationPage(props: AutomationPageProps) {
|
||||
const [runs, setRuns] = createSignal<Record<string, ScheduledJobRun[]>>({});
|
||||
const [expandedJob, setExpandedJob] = createSignal<string>();
|
||||
const [showCreate, setShowCreate] = createSignal(false);
|
||||
const [jobForm, setJobForm] = createSignal({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true });
|
||||
const [jobForm, setJobForm] = createSignal({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true, target: "url" as "url" | "function", function_id: "" });
|
||||
const [functions, setFunctions] = createSignal<PrimoraFunction[]>([]);
|
||||
const [createdSecret, setCreatedSecret] = createSignal("");
|
||||
const [loading, setLoading] = createSignal(false);
|
||||
const [busy, setBusy] = createSignal<string>();
|
||||
@@ -82,6 +85,10 @@ export function AutomationPage(props: AutomationPageProps) {
|
||||
try {
|
||||
const res = await svc(props.demoMode).listScheduledJobs({ projectId: props.projectID });
|
||||
setJobs(res.items ?? []);
|
||||
if (!props.demoMode) {
|
||||
const fr = await FunctionsService.listFunctions({ projectId: props.projectID });
|
||||
setFunctions(fr.items ?? []);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
@@ -199,14 +206,15 @@ export function AutomationPage(props: AutomationPageProps) {
|
||||
requestBody: {
|
||||
name: form.name.trim(),
|
||||
schedule: form.schedule.trim(),
|
||||
url: form.url.trim(),
|
||||
url: form.target === "url" ? form.url.trim() : undefined,
|
||||
function_id: form.target === "function" ? form.function_id : undefined,
|
||||
secret: form.secret.trim() || undefined,
|
||||
payload,
|
||||
enabled: form.enabled,
|
||||
},
|
||||
});
|
||||
if (created.secret) setCreatedSecret(created.secret);
|
||||
setJobForm({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true });
|
||||
setJobForm({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true, target: "url", function_id: "" });
|
||||
setShowCreate(false);
|
||||
}, "Schedule created");
|
||||
};
|
||||
@@ -335,7 +343,9 @@ export function AutomationPage(props: AutomationPageProps) {
|
||||
<code class="text-xs">{job.schedule}</code>
|
||||
</td>
|
||||
<td class="text-text-2" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis">
|
||||
{job.url}
|
||||
{job.function_id
|
||||
? `ƒ ${functions().find((f) => f.id === job.function_id)?.name ?? "function"}`
|
||||
: job.url}
|
||||
</td>
|
||||
<td class="hidden md:table-cell text-text-2">{job.enabled ? fmtTime(job.next_run_at) : "—"}</td>
|
||||
<td class="hidden md:table-cell">
|
||||
@@ -545,16 +555,56 @@ export function AutomationPage(props: AutomationPageProps) {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
label="Target URL"
|
||||
value={jobForm().url}
|
||||
onInput={(e) => setJobForm((c) => ({ ...c, url: e.currentTarget.value }))}
|
||||
placeholder="https://api.example.com/jobs/rollup"
|
||||
required
|
||||
/>
|
||||
<p class="label-hint">
|
||||
HTTPS for public endpoints; plain HTTP allowed for private/self-hosted targets.
|
||||
</p>
|
||||
<span class="label">Target</span>
|
||||
<div class="flex gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class={`btn btn-sm ${jobForm().target === "url" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setJobForm((c) => ({ ...c, target: "url" }))}
|
||||
>
|
||||
Webhook URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`btn btn-sm ${jobForm().target === "function" ? "btn-primary" : "btn-secondary"}`}
|
||||
onClick={() => setJobForm((c) => ({ ...c, target: "function" }))}
|
||||
>
|
||||
Function
|
||||
</button>
|
||||
</div>
|
||||
<Show
|
||||
when={jobForm().target === "function"}
|
||||
fallback={
|
||||
<>
|
||||
<Input
|
||||
label="Target URL"
|
||||
value={jobForm().url}
|
||||
onInput={(e) => setJobForm((c) => ({ ...c, url: e.currentTarget.value }))}
|
||||
placeholder="https://api.example.com/jobs/rollup"
|
||||
required
|
||||
/>
|
||||
<p class="label-hint">
|
||||
HTTPS for public endpoints; plain HTTP allowed for private/self-hosted targets.
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<select
|
||||
class="input"
|
||||
value={jobForm().function_id}
|
||||
onChange={(e) => setJobForm((c) => ({ ...c, function_id: e.currentTarget.value }))}
|
||||
required
|
||||
>
|
||||
<option value="">Select a function…</option>
|
||||
<For each={functions()}>
|
||||
{(f) => <option value={f.id}>{f.name} ({f.runtime})</option>}
|
||||
</For>
|
||||
</select>
|
||||
<p class="label-hint">
|
||||
The function receives the resolved payload as JSON. Runs appear under the function's
|
||||
history.
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
<Textarea
|
||||
label="Payload (optional JSON)"
|
||||
|
||||
@@ -42,7 +42,9 @@ export function FunctionsPage(props: FunctionsPageProps) {
|
||||
const [error, setError] = createSignal("");
|
||||
const [busy, setBusy] = createSignal<string>();
|
||||
const [showCreate, setShowCreate] = createSignal(false);
|
||||
const [createForm, setCreateForm] = createSignal({ name: "", runtime: "bun" });
|
||||
const [createForm, setCreateForm] = createSignal({ name: "", runtime: "bun", event_pattern: "" });
|
||||
const [eventPattern, setEventPattern] = createSignal("");
|
||||
const [patternDirty, setPatternDirty] = createSignal(false);
|
||||
|
||||
const refresh = async () => {
|
||||
if (!props.projectID) return;
|
||||
@@ -71,6 +73,8 @@ export function FunctionsPage(props: FunctionsPageProps) {
|
||||
const select = async (fn: Function) => {
|
||||
setSelected(fn);
|
||||
setCodeDirty(false);
|
||||
setEventPattern(fn.event_pattern ?? "");
|
||||
setPatternDirty(false);
|
||||
setLastRun(undefined);
|
||||
if (props.demoMode) {
|
||||
setCode(STARTER_CODE);
|
||||
@@ -94,10 +98,15 @@ export function FunctionsPage(props: FunctionsPageProps) {
|
||||
try {
|
||||
const fn = await FunctionsService.createFunction({
|
||||
projectId: props.projectID,
|
||||
requestBody: { name: createForm().name, runtime: createForm().runtime as CreateFunctionRequest.runtime, code: STARTER_CODE },
|
||||
requestBody: {
|
||||
name: createForm().name,
|
||||
runtime: createForm().runtime as CreateFunctionRequest.runtime,
|
||||
code: STARTER_CODE,
|
||||
event_pattern: createForm().event_pattern.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setShowCreate(false);
|
||||
setCreateForm({ name: "", runtime: "bun" });
|
||||
setCreateForm({ name: "", runtime: "bun", event_pattern: "" });
|
||||
setMessage(`Function "${fn.name}" created.`);
|
||||
await refresh();
|
||||
await select(fn);
|
||||
@@ -113,8 +122,15 @@ export function FunctionsPage(props: FunctionsPageProps) {
|
||||
if (!fn || !props.projectID || props.demoMode) return;
|
||||
setBusy("save");
|
||||
try {
|
||||
await FunctionsService.updateFunction({ projectId: props.projectID, functionId: fn.id, requestBody: { code: code() } });
|
||||
await FunctionsService.updateFunction({
|
||||
projectId: props.projectID,
|
||||
functionId: fn.id,
|
||||
requestBody: { code: code(), event_pattern: eventPattern().trim() },
|
||||
});
|
||||
setCodeDirty(false);
|
||||
setPatternDirty(false);
|
||||
setSelected({ ...fn, event_pattern: eventPattern().trim() });
|
||||
setFunctions((list) => list.map((f) => (f.id === fn.id ? { ...f, event_pattern: eventPattern().trim() } : f)));
|
||||
setMessage(`Saved "${fn.name}".`);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
@@ -286,10 +302,13 @@ export function FunctionsPage(props: FunctionsPageProps) {
|
||||
<div class="flex items-center justify-between" style="width:100%">
|
||||
<span class="card-header-title">
|
||||
{fn().name} <Badge variant="neutral">{fn().runtime}</Badge>
|
||||
<Show when={fn().event_pattern}>
|
||||
<Badge variant="warning">on {fn().event_pattern}</Badge>
|
||||
</Show>
|
||||
</span>
|
||||
<div class="flex gap-2 items-center">
|
||||
<Show when={props.canManage && !props.demoMode}>
|
||||
<button class="btn btn-secondary btn-sm" disabled={!codeDirty() || busy() === "save"} onClick={() => void save()}>
|
||||
<button class="btn btn-secondary btn-sm" disabled={(!codeDirty() && !patternDirty()) || busy() === "save"} onClick={() => void save()}>
|
||||
{busy() === "save" ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" disabled={busy() === "invoke" || !fn().enabled} onClick={() => void invoke()}>
|
||||
@@ -299,6 +318,21 @@ export function FunctionsPage(props: FunctionsPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding:1rem 1.25rem 0">
|
||||
<Input
|
||||
label="Event trigger (optional)"
|
||||
placeholder='document.* · * · deploy.marker'
|
||||
value={eventPattern()}
|
||||
onInput={(e) => { setEventPattern(e.currentTarget.value); setPatternDirty(true); }}
|
||||
disabled={!props.canManage || props.demoMode}
|
||||
/>
|
||||
<p class="text-text-3" style="font-size:0.78rem;margin-top:0.3rem">
|
||||
Runs whenever a project event matching this pattern is published — e.g.{" "}
|
||||
<code>document.*</code>, <code>object.created</code>, or <code>*</code> for all. The
|
||||
function receives {"{ event, data, occurred_at }"}. Schedules and inbound hooks can
|
||||
also target functions.
|
||||
</p>
|
||||
</div>
|
||||
<div style="padding:1rem 1.25rem;display:grid;gap:1rem;grid-template-columns:1fr 1fr">
|
||||
<div>
|
||||
<label class="text-text-3" style="font-size:0.8rem;display:block;margin-bottom:0.35rem">Code</label>
|
||||
@@ -393,6 +427,12 @@ export function FunctionsPage(props: FunctionsPageProps) {
|
||||
<option value="bun">bun</option>
|
||||
<option value="deno">deno</option>
|
||||
</Select>
|
||||
<Input
|
||||
label="Event trigger (optional)"
|
||||
placeholder="document.*"
|
||||
value={createForm().event_pattern}
|
||||
onInput={(e) => setCreateForm((f) => ({ ...f, event_pattern: e.currentTarget.value }))}
|
||||
/>
|
||||
<p class="text-text-3" style="font-size:0.8rem">
|
||||
Starts from a stdin-echo template — edit the code after creating.
|
||||
</p>
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
AutomationService,
|
||||
CreateInboundHookRequest,
|
||||
CreateIntegrationRequest,
|
||||
FunctionsService,
|
||||
InboundService,
|
||||
IntegrationsService,
|
||||
UpsertAlertRuleRequest,
|
||||
WebhooksService,
|
||||
type AlertRule,
|
||||
type Function as PrimoraFunction,
|
||||
type InboundHook,
|
||||
type ScheduledJob,
|
||||
type Integration,
|
||||
@@ -74,7 +76,8 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
const [showCreateWebhook, setShowCreateWebhook] = createSignal(false);
|
||||
const [showCreateAlert, setShowCreateAlert] = createSignal(false);
|
||||
const [showCreateInbound, setShowCreateInbound] = createSignal(false);
|
||||
const [inboundForm, setInboundForm] = createSignal({ name: "", mode: CreateInboundHookRequest.mode.EVENT as CreateInboundHookRequest.mode, job_id: "", secret: "" });
|
||||
const [inboundForm, setInboundForm] = createSignal({ name: "", mode: CreateInboundHookRequest.mode.EVENT as CreateInboundHookRequest.mode, job_id: "", function_id: "", secret: "" });
|
||||
const [functions, setFunctions] = createSignal<PrimoraFunction[]>([]);
|
||||
const [copiedUrl, setCopiedUrl] = createSignal("");
|
||||
const [alertForm, setAlertForm] = createSignal({
|
||||
name: "",
|
||||
@@ -97,18 +100,20 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [int, hooks, rules, inbound, jobs] = await Promise.all([
|
||||
const [int, hooks, rules, inbound, jobs, fns] = await Promise.all([
|
||||
intSvc(props.demoMode).listIntegrations({ projectId: props.projectID }),
|
||||
hookSvc(props.demoMode).listWebhooks({ projectId: props.projectID }),
|
||||
props.demoMode ? Promise.resolve({ items: [] }) : AlertsService.listAlertRules({ projectId: props.projectID }),
|
||||
props.demoMode ? Promise.resolve({ items: [] }) : InboundService.listInboundHooks({ projectId: props.projectID }),
|
||||
props.demoMode ? Promise.resolve({ items: [] }) : AutomationService.listScheduledJobs({ projectId: props.projectID }),
|
||||
props.demoMode ? Promise.resolve({ items: [] }) : FunctionsService.listFunctions({ projectId: props.projectID }),
|
||||
]);
|
||||
setIntegrations(int.items ?? []);
|
||||
setWebhooks(hooks.items ?? []);
|
||||
setAlertRules(rules.items ?? []);
|
||||
setInboundHooks(inbound.items ?? []);
|
||||
setScheduledJobs(jobs.items ?? []);
|
||||
setFunctions(fns.items ?? []);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
@@ -268,11 +273,12 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
name: form.name.trim(),
|
||||
mode: form.mode,
|
||||
job_id: form.mode === CreateInboundHookRequest.mode.JOB ? form.job_id : undefined,
|
||||
function_id: form.mode === CreateInboundHookRequest.mode.FUNCTION ? form.function_id : undefined,
|
||||
secret: form.secret.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setShowCreateInbound(false);
|
||||
setInboundForm({ name: "", mode: CreateInboundHookRequest.mode.EVENT, job_id: "", secret: "" });
|
||||
setInboundForm({ name: "", mode: CreateInboundHookRequest.mode.EVENT, job_id: "", function_id: "", secret: "" });
|
||||
}, "Inbound hook created");
|
||||
};
|
||||
|
||||
@@ -706,7 +712,9 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
<tr>
|
||||
<td class="font-medium">{hook.name}</td>
|
||||
<td>
|
||||
<Badge variant="neutral">{hook.mode === "job" ? "job trigger" : "event"}</Badge>
|
||||
<Badge variant="neutral">
|
||||
{hook.mode === "job" ? "job trigger" : hook.mode === "function" ? `ƒ ${functions().find((f) => f.id === hook.function_id)?.name ?? "function"}` : "event"}
|
||||
</Badge>
|
||||
<Show when={!hook.enabled}>
|
||||
{" "}<Badge variant="warning">disabled</Badge>
|
||||
</Show>
|
||||
@@ -943,8 +951,21 @@ export function IntegrationsPage(props: IntegrationsPageProps) {
|
||||
options={[
|
||||
{ value: "event", label: "Publish inbound.received event" },
|
||||
{ value: "job", label: "Trigger a scheduled job" },
|
||||
{ value: "function", label: "Invoke a function" },
|
||||
]}
|
||||
/>
|
||||
<Show when={inboundForm().mode === CreateInboundHookRequest.mode.FUNCTION}>
|
||||
<Select
|
||||
label="Function"
|
||||
value={inboundForm().function_id}
|
||||
onChange={(e) => setInboundForm((c) => ({ ...c, function_id: e.currentTarget.value }))}
|
||||
options={[
|
||||
{ value: "", label: "Select a function…" },
|
||||
...functions().map((f) => ({ value: f.id, label: f.name })),
|
||||
]}
|
||||
/>
|
||||
<p class="text-text-2 text-xs">The received body becomes the function's stdin payload.</p>
|
||||
</Show>
|
||||
<Show when={inboundForm().mode === CreateInboundHookRequest.mode.JOB}>
|
||||
<Select
|
||||
label="Job"
|
||||
|
||||
Reference in new issue
Block a user