mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
feat: Functions — JS/TS execution with run log
core.functions + core.function_runs: project-scoped code executed by a runtime binary on the backend host (bun or deno per function). Payload arrives on stdin + PRIMORA_PAYLOAD env; stdout/stderr captured per run (capped), with status/exit code/duration. Invoke returns 502 + the recorded run on failure so errors stay observable. Dashboard gains a Functions page: code editor, JSON payload invoke, live result, run history. Runner config via FUNCTIONS_RUNTIME / FUNCTIONS_TIMEOUT_SECONDS / FUNCTIONS_MAX_OUTPUT_BYTES. Verified live: bun ran code, stdin payload delivered, error path returns 502 with captured stderr; timeout kills at deadline. 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
3aca5bd1e0
commit
32587c8063
37 files changed
+2157
-6
No files matched your search
@@ -48,6 +48,7 @@ import {
|
||||
TelemetryPage,
|
||||
IntegrationsPage,
|
||||
VaultPage,
|
||||
FunctionsPage,
|
||||
LoginPage,
|
||||
MembersPage,
|
||||
ProjectsPage,
|
||||
@@ -63,6 +64,7 @@ import {
|
||||
IconDatabases,
|
||||
IconTelemetry,
|
||||
IconZap,
|
||||
IconTerminal,
|
||||
IconCollections,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
@@ -1533,6 +1535,7 @@ export default function App() {
|
||||
{ id: "nav-telemetry", label: "Go to Telemetry", category: "Navigate", icon: <IconTelemetry class="w-4 h-4" />, keywords: ["errors", "metrics", "logs", "observability", "issues"], action: () => setActiveView("telemetry") },
|
||||
{ id: "nav-automation", label: "Go to Automation", category: "Navigate", icon: <IconZap class="w-4 h-4" />, keywords: ["cron", "jobs", "schedule", "events", "realtime"], action: () => setActiveView("automation") },
|
||||
{ id: "nav-vault", label: "Go to Vault", category: "Navigate", icon: <IconKey class="w-4 h-4" />, keywords: ["secrets", "env", "credentials", "api key", "token"], action: () => setActiveView("vault") },
|
||||
{ id: "nav-functions", label: "Go to Functions", category: "Navigate", icon: <IconTerminal class="w-4 h-4" />, keywords: ["serverless", "code", "lambda", "invoke", "edge"], action: () => setActiveView("functions") },
|
||||
{ id: "nav-storage", label: "Go to Storage", category: "Navigate", icon: <IconStorage class="w-4 h-4" />, keywords: ["files", "buckets", "upload"], action: () => setActiveView("storage") },
|
||||
{ id: "nav-collections", label: "Go to Collections", category: "Navigate", icon: <IconCollections class="w-4 h-4" />, keywords: ["documents", "database"], action: () => setActiveView("collections") },
|
||||
{ id: "nav-auth", label: "Go to Authentication", category: "Navigate", icon: <IconAuth class="w-4 h-4" />, keywords: ["users", "sign in", "ban"], action: () => setActiveView("auth") },
|
||||
@@ -1784,6 +1787,14 @@ export default function App() {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "functions"}>
|
||||
<FunctionsPage
|
||||
projectID={activeProject()?.id}
|
||||
canManage={canUpdateProject()}
|
||||
demoMode={isDemo}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeView() === "storage"}>
|
||||
<StoragePage
|
||||
buckets={buckets()}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
IconTelemetry,
|
||||
IconIntegrations,
|
||||
IconZap,
|
||||
IconTerminal,
|
||||
IconKey,
|
||||
IconAudit,
|
||||
IconAuth,
|
||||
@@ -33,6 +34,7 @@ export type ViewType =
|
||||
| "telemetry"
|
||||
| "integrations"
|
||||
| "automation"
|
||||
| "functions"
|
||||
| "vault"
|
||||
| "auth"
|
||||
| "audit"
|
||||
@@ -51,6 +53,7 @@ const projectNav: NavEntry[] = [
|
||||
{ id: "telemetry", label: "Telemetry", shortLabel: "Signals", icon: IconTelemetry },
|
||||
{ id: "integrations", label: "Integrations", shortLabel: "Integr.", icon: IconIntegrations },
|
||||
{ id: "automation", label: "Automation", shortLabel: "Auto", icon: IconZap },
|
||||
{ id: "functions", label: "Functions", shortLabel: "Funcs", icon: IconTerminal },
|
||||
{ id: "vault", label: "Vault", icon: IconKey },
|
||||
{ id: "storage", label: "Storage", icon: IconStorage },
|
||||
{ id: "collections", label: "Collections", icon: IconCollections },
|
||||
@@ -99,6 +102,7 @@ const viewTitle: Record<ViewType, string> = {
|
||||
telemetry: "Telemetry",
|
||||
integrations: "Integrations",
|
||||
automation: "Automation",
|
||||
functions: "Functions",
|
||||
vault: "Vault",
|
||||
auth: "Authentication",
|
||||
audit: "Audit log",
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { For, Show, createEffect, createSignal } from "solid-js";
|
||||
import {
|
||||
ApiError,
|
||||
CreateFunctionRequest,
|
||||
Function,
|
||||
FunctionsService,
|
||||
type FunctionRun,
|
||||
} from "@primora/api-client";
|
||||
import { Modal, ModalFooter } from "../components/Modal";
|
||||
import { Input, Select, Textarea } from "../components/Input";
|
||||
import { Badge } from "../components/Badge";
|
||||
import { IconPlus, IconTrash, IconPlay, IconTerminal } from "../components/Icons";
|
||||
|
||||
interface FunctionsPageProps {
|
||||
projectID?: string;
|
||||
canManage: boolean;
|
||||
demoMode: boolean;
|
||||
}
|
||||
|
||||
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const fmtTime = (ts?: string) => (ts ? new Date(ts).toLocaleString() : "—");
|
||||
|
||||
const STARTER_CODE = `// The payload arrives on stdin and as PRIMORA_PAYLOAD.
|
||||
const input = JSON.parse(await new Response(Bun.stdin.stream()).text());
|
||||
console.log(JSON.stringify({ echo: input }));
|
||||
`;
|
||||
|
||||
const DEMO_FUNCTIONS: Function[] = [
|
||||
{ id: "f1", name: "resize-image", runtime: Function.runtime.BUN, enabled: true, created_at: "", updated_at: "2026-09-20T09:00:00Z" },
|
||||
{ id: "f2", name: "sync-billing", runtime: Function.runtime.BUN, enabled: false, created_at: "", updated_at: "2026-09-15T14:00:00Z" },
|
||||
];
|
||||
|
||||
export function FunctionsPage(props: FunctionsPageProps) {
|
||||
const [functions, setFunctions] = createSignal<Function[]>([]);
|
||||
const [selected, setSelected] = createSignal<Function>();
|
||||
const [code, setCode] = createSignal("");
|
||||
const [codeDirty, setCodeDirty] = createSignal(false);
|
||||
const [runs, setRuns] = createSignal<FunctionRun[]>([]);
|
||||
const [payload, setPayload] = createSignal("{}");
|
||||
const [lastRun, setLastRun] = createSignal<FunctionRun>();
|
||||
const [message, setMessage] = createSignal("");
|
||||
const [error, setError] = createSignal("");
|
||||
const [busy, setBusy] = createSignal<string>();
|
||||
const [showCreate, setShowCreate] = createSignal(false);
|
||||
const [createForm, setCreateForm] = createSignal({ name: "", runtime: "bun" });
|
||||
|
||||
const refresh = async () => {
|
||||
if (!props.projectID) return;
|
||||
if (props.demoMode) {
|
||||
setFunctions(DEMO_FUNCTIONS);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await FunctionsService.listFunctions({ projectId: props.projectID });
|
||||
setFunctions(res.items ?? []);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshRuns = async (fn: Function) => {
|
||||
if (!props.projectID || props.demoMode) return;
|
||||
try {
|
||||
const res = await FunctionsService.listFunctionRuns({ projectId: props.projectID, functionId: fn.id, limit: 20 });
|
||||
setRuns(res.items ?? []);
|
||||
} catch {
|
||||
/* run history is best-effort */
|
||||
}
|
||||
};
|
||||
|
||||
const select = async (fn: Function) => {
|
||||
setSelected(fn);
|
||||
setCodeDirty(false);
|
||||
setLastRun(undefined);
|
||||
if (props.demoMode) {
|
||||
setCode(STARTER_CODE);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await FunctionsService.getFunction({ projectId: props.projectID!, functionId: fn.id });
|
||||
setCode(res.code);
|
||||
await refreshRuns(fn);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
}
|
||||
};
|
||||
|
||||
createEffect(() => void refresh());
|
||||
|
||||
const create = async (e: SubmitEvent) => {
|
||||
e.preventDefault();
|
||||
if (!props.projectID || props.demoMode) return;
|
||||
setBusy("create");
|
||||
try {
|
||||
const fn = await FunctionsService.createFunction({
|
||||
projectId: props.projectID,
|
||||
requestBody: { name: createForm().name, runtime: createForm().runtime as CreateFunctionRequest.runtime, code: STARTER_CODE },
|
||||
});
|
||||
setShowCreate(false);
|
||||
setCreateForm({ name: "", runtime: "bun" });
|
||||
setMessage(`Function "${fn.name}" created.`);
|
||||
await refresh();
|
||||
await select(fn);
|
||||
} catch (e2) {
|
||||
setError(err(e2));
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const fn = selected();
|
||||
if (!fn || !props.projectID || props.demoMode) return;
|
||||
setBusy("save");
|
||||
try {
|
||||
await FunctionsService.updateFunction({ projectId: props.projectID, functionId: fn.id, requestBody: { code: code() } });
|
||||
setCodeDirty(false);
|
||||
setMessage(`Saved "${fn.name}".`);
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const invoke = async () => {
|
||||
const fn = selected();
|
||||
if (!fn || !props.projectID || props.demoMode) return;
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(payload() || "{}");
|
||||
} catch {
|
||||
setError("Payload is not valid JSON.");
|
||||
return;
|
||||
}
|
||||
setBusy("invoke");
|
||||
setError("");
|
||||
try {
|
||||
const run = await FunctionsService.invokeFunction({
|
||||
projectId: props.projectID,
|
||||
functionId: fn.id,
|
||||
requestBody: { payload: parsed },
|
||||
});
|
||||
setLastRun(run);
|
||||
} catch (e) {
|
||||
// 502 still returns a FunctionRun body — the run is recorded either way.
|
||||
if (e instanceof ApiError && e.body && typeof e.body === "object" && "status" in e.body) {
|
||||
setLastRun(e.body as FunctionRun);
|
||||
} else {
|
||||
setError(err(e));
|
||||
}
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
await refreshRuns(fn);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleEnabled = async (fn: Function) => {
|
||||
if (!props.projectID || props.demoMode) return;
|
||||
try {
|
||||
await FunctionsService.updateFunction({ projectId: props.projectID, functionId: fn.id, requestBody: { enabled: !fn.enabled } });
|
||||
await refresh();
|
||||
if (selected()?.id === fn.id) setSelected({ ...fn, enabled: !fn.enabled });
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (fn: Function) => {
|
||||
if (!props.projectID || props.demoMode) return;
|
||||
setBusy(fn.id);
|
||||
try {
|
||||
await FunctionsService.deleteFunction({ projectId: props.projectID, functionId: fn.id });
|
||||
if (selected()?.id === fn.id) setSelected(undefined);
|
||||
setMessage(`Deleted "${fn.name}".`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(err(e));
|
||||
} finally {
|
||||
setBusy(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const runBadge = (status: string) =>
|
||||
status === "success" ? "success" : status === "timeout" ? "warning" : "error";
|
||||
|
||||
return (
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">Functions</h1>
|
||||
<p class="page-description">
|
||||
Serverless JS/TS executed by a runtime on this instance (<code>bun</code> or <code>deno</code>).
|
||||
The payload arrives on stdin and as <code>PRIMORA_PAYLOAD</code>; stdout is captured per run.
|
||||
</p>
|
||||
</div>
|
||||
<Show when={props.canManage && !props.demoMode}>
|
||||
<button class="btn btn-primary" onClick={() => setShowCreate(true)}>
|
||||
<IconPlus class="w-4 h-4" /> New function
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<Show when={message()}>
|
||||
<div class="message message-success" onClick={() => setMessage("")}>{message()}</div>
|
||||
</Show>
|
||||
<Show when={error()}>
|
||||
<div class="message message-error" onClick={() => setError("")}>{error()}</div>
|
||||
</Show>
|
||||
|
||||
<Show
|
||||
when={!props.projectID && !props.demoMode ? false : true}
|
||||
fallback={<div class="card card-flush empty-state">Select a project to manage functions.</div>}
|
||||
>
|
||||
<Show
|
||||
when={functions().length > 0}
|
||||
fallback={
|
||||
<div class="card card-flush empty-state">
|
||||
<IconTerminal class="w-8 h-8" />
|
||||
<p>No functions yet.</p>
|
||||
<p class="text-text-3" style="font-size:0.85rem">
|
||||
Functions run your code on demand — invoke them from the dashboard, the API, or wire them to schedules and hooks.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="card card-flush">
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Runtime</th>
|
||||
<th>Status</th>
|
||||
<th>Updated</th>
|
||||
<th style="width:1%"> </th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={functions()}>
|
||||
{(fn) => (
|
||||
<tr
|
||||
style={selected()?.id === fn.id ? "background:var(--bg-3)" : "cursor:pointer"}
|
||||
onClick={() => void select(fn)}
|
||||
>
|
||||
<td class="font-medium text-text-1">{fn.name}</td>
|
||||
<td><Badge variant="neutral">{fn.runtime}</Badge></td>
|
||||
<td>
|
||||
<Badge variant={fn.enabled ? "success" : "neutral"}>{fn.enabled ? "enabled" : "disabled"}</Badge>
|
||||
</td>
|
||||
<td class="text-text-3">{fmtTime(fn.updated_at)}</td>
|
||||
<td>
|
||||
<Show when={props.canManage && !props.demoMode}>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
title={fn.enabled ? "Disable" : "Enable"}
|
||||
onClick={(e) => { e.stopPropagation(); void toggleEnabled(fn); }}
|
||||
>
|
||||
{fn.enabled ? "⏸" : "▶"}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-sm text-danger"
|
||||
disabled={busy() === fn.id}
|
||||
onClick={(e) => { e.stopPropagation(); void remove(fn); }}
|
||||
>
|
||||
<IconTrash class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</Show>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
<Show when={selected()}>
|
||||
{(fn) => (
|
||||
<div class="card card-flush" style="margin-top:1.25rem">
|
||||
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
|
||||
<div class="flex items-center justify-between" style="width:100%">
|
||||
<span class="card-header-title">
|
||||
{fn().name} <Badge variant="neutral">{fn().runtime}</Badge>
|
||||
</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()}>
|
||||
{busy() === "save" ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" disabled={busy() === "invoke" || !fn().enabled} onClick={() => void invoke()}>
|
||||
<IconPlay class="w-3.5 h-3.5" /> {busy() === "invoke" ? "Running…" : "Invoke"}
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<Textarea
|
||||
rows={14}
|
||||
value={code()}
|
||||
onInput={(e) => { setCode(e.currentTarget.value); setCodeDirty(true); }}
|
||||
disabled={!props.canManage || props.demoMode}
|
||||
style="font-family:var(--font-mono);font-size:0.8rem"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-text-3" style="font-size:0.8rem;display:block;margin-bottom:0.35rem">Payload (JSON)</label>
|
||||
<Textarea
|
||||
rows={5}
|
||||
value={payload()}
|
||||
onInput={(e) => setPayload(e.currentTarget.value)}
|
||||
disabled={!props.canManage || props.demoMode}
|
||||
style="font-family:var(--font-mono);font-size:0.8rem"
|
||||
/>
|
||||
<Show when={lastRun()}>
|
||||
{(run) => (
|
||||
<div style="margin-top:0.75rem">
|
||||
<div class="flex items-center gap-2" style="margin-bottom:0.35rem">
|
||||
<Badge variant={runBadge(run().status)}>{run().status}</Badge>
|
||||
<span class="text-text-3" style="font-size:0.8rem">
|
||||
{run().duration_ms} ms{run().exit_code != null ? ` · exit ${run().exit_code}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<Show when={run().stdout}>
|
||||
<pre class="text-text-2" style="background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:0.6rem;font-size:0.78rem;max-height:10rem;overflow:auto;white-space:pre-wrap">{run().stdout}</pre>
|
||||
</Show>
|
||||
<Show when={run().stderr}>
|
||||
<pre class="text-danger" style="background:var(--bg-2);border:1px solid var(--border);border-radius:var(--radius);padding:0.6rem;font-size:0.78rem;max-height:10rem;overflow:auto;white-space:pre-wrap">{run().stderr}</pre>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={selected() && runs().length > 0}>
|
||||
<div class="card card-flush" style="margin-top:1.25rem">
|
||||
<div class="card-header" style="padding:1.25rem 1.25rem 1rem;margin-bottom:0;border-bottom:1px solid var(--border)">
|
||||
<span class="card-header-title">Recent runs</span>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Trigger</th>
|
||||
<th>Duration</th>
|
||||
<th>When</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={runs()}>
|
||||
{(run) => (
|
||||
<tr>
|
||||
<td><Badge variant={runBadge(run.status)}>{run.status}</Badge></td>
|
||||
<td class="text-text-2">{run.trigger}</td>
|
||||
<td class="text-text-3">{run.duration_ms} ms</td>
|
||||
<td class="text-text-3">{fmtTime(run.created_at)}</td>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New function">
|
||||
<form onSubmit={create}>
|
||||
<div class="modal-body">
|
||||
<Input
|
||||
label="Name"
|
||||
placeholder="resize-image"
|
||||
value={createForm().name}
|
||||
onInput={(e) => setCreateForm((f) => ({ ...f, name: e.currentTarget.value }))}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Runtime"
|
||||
value={createForm().runtime}
|
||||
onChange={(e) => setCreateForm((f) => ({ ...f, runtime: e.currentTarget.value }))}
|
||||
>
|
||||
<option value="bun">bun</option>
|
||||
<option value="deno">deno</option>
|
||||
</Select>
|
||||
<p class="text-text-3" style="font-size:0.8rem">
|
||||
Starts from a stdin-echo template — edit the code after creating.
|
||||
</p>
|
||||
</div>
|
||||
<ModalFooter>
|
||||
<button type="button" class="btn btn-secondary" onClick={() => setShowCreate(false)}>Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={busy() === "create" || !createForm().name.trim()}>
|
||||
{busy() === "create" ? "Creating…" : "Create function"}
|
||||
</button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export { DatabasesPage } from "./DatabasesPage";
|
||||
export { TelemetryPage } from "./TelemetryPage";
|
||||
export { IntegrationsPage } from "./IntegrationsPage";
|
||||
export { VaultPage } from "./VaultPage";
|
||||
export { FunctionsPage } from "./FunctionsPage";
|
||||
export { AutomationPage } from "./AutomationPage";
|
||||
export { SettingsPage } from "./SettingsPage";
|
||||
export { AuditPage } from "./AuditPage";
|
||||
|
||||
Reference in new issue
Block a user