feat: phase 7 automation & realtime — scheduled jobs, domain events, SSE

Scheduled jobs (migration 00009): cron expressions + @every descriptors,
in-process scheduler (15s scan, missed-run collapse), signed HTTP POST
delivery (X-Primora-Signature), AES-256-GCM sealed secrets returned once,
run history with status/duration/http-code, manual execution, retention
pruning. Viewer reads, admin/developer writes.

Domain events: publishEvent fans document/object/issue/deploy/job
mutations to realtime subscribers and matching webhooks through one
path. Webhook event filter extended to job.run, document.*, object.*.

Realtime: GET /projects/:id/realtime/stream SSE with 25s keepalives and
?token=/?api_key= auth for EventSource clients.

Automation dashboard page: Schedules tab (CRUD, run history, manual run,
secret-once banner) + Live events tab (live badge, pause/clear). Demo
mode parity. primora jobs:* CLI group, six primora_*_job* MCP tools,
AutomationService in the generated client.

Fixes folded in: gzip middleware buffered SSE flushes (broke EventSource
on both streams — */stream now bypasses compression); event payloads
double-marshaled json.RawMessage as base64; compose hardcoded localhost
trusted origins so non-default ports broke sign-in; frontend auth base
URL baked an absolute localhost instead of resolving /auth on origin.

Verified live: stack up on :8085, signed delivery HMAC-checked at sink,
@every 30s schedule firing, run history rendering, document.created
landing in the live events feed.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-18 15:15:01 +02:00
1 parent 4b7fdf35ad
commit 9e9a3d5f98
49 files changed
+3555 -32

No files matched your search

+11
View File
@@ -39,6 +39,7 @@ import {
import {
AuditPage,
AuthPage,
AutomationPage,
type AuthUser,
CollectionsPage,
DatabasesPage,
@@ -58,6 +59,7 @@ import {
IconStorage,
IconDatabases,
IconTelemetry,
IconZap,
IconCollections,
IconAudit,
IconAuth,
@@ -1457,6 +1459,7 @@ export default function App() {
{ id: "nav-members", label: "Go to Members", category: "Navigate", icon: <IconMembers class="w-4 h-4" />, keywords: ["team", "users", "invite"], action: () => setActiveView("members") },
{ id: "nav-databases", label: "Go to Databases", category: "Navigate", icon: <IconDatabases class="w-4 h-4" />, keywords: ["db", "sql", "query", "tables", "redis"], action: () => setActiveView("databases") },
{ 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-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") },
@@ -1687,6 +1690,14 @@ export default function App() {
/>
</Show>
<Show when={activeView() === "automation"}>
<AutomationPage
projectID={activeProject()?.id}
canManage={canUpdateProject()}
demoMode={isDemo}
/>
</Show>
<Show when={activeView() === "storage"}>
<StoragePage
buckets={buckets()}
+5 -1
View File
@@ -10,6 +10,7 @@ import {
IconDatabases,
IconTelemetry,
IconIntegrations,
IconZap,
IconAudit,
IconAuth,
IconSettings,
@@ -30,6 +31,7 @@ export type ViewType =
| "databases"
| "telemetry"
| "integrations"
| "automation"
| "auth"
| "audit"
| "settings";
@@ -46,6 +48,7 @@ const projectNav: NavEntry[] = [
{ id: "databases", label: "Databases", shortLabel: "DBs", icon: IconDatabases },
{ 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: "storage", label: "Storage", icon: IconStorage },
{ id: "collections", label: "Collections", icon: IconCollections },
{ id: "auth", label: "Authentication", shortLabel: "Auth", icon: IconAuth },
@@ -53,7 +56,7 @@ const projectNav: NavEntry[] = [
];
// Instagram-style bottom bar caps at five slots — the rest stay reachable via Menu.
const bottomNav: NavEntry[] = projectNav.filter((e) => !["collections", "telemetry", "integrations"].includes(e.id));
const bottomNav: NavEntry[] = projectNav.filter((e) => !["collections", "telemetry", "integrations", "automation"].includes(e.id));
const workspaceNav: NavEntry[] = [
{ id: "projects", label: "Projects", icon: IconProjects },
@@ -92,6 +95,7 @@ const viewTitle: Record<ViewType, string> = {
databases: "Databases",
telemetry: "Telemetry",
integrations: "Integrations",
automation: "Automation",
auth: "Authentication",
audit: "Audit log",
settings: "Settings",
+6 -2
View File
@@ -1,8 +1,12 @@
import { createAuthClient } from "better-auth/solid";
import { adminClient } from "better-auth/client/plugins";
// Use full URL for dev mode, relative path for production
const baseURL = import.meta.env.VITE_AUTH_BASE_URL ?? "http://localhost/auth";
// Same-origin default — nginx routes /auth to the auth service at whatever
// port or domain serves the app. better-auth requires an absolute baseURL, so
// resolve it against the runtime origin; VITE_AUTH_BASE_URL overrides for
// split-origin setups.
const baseURL =
import.meta.env.VITE_AUTH_BASE_URL ?? new URL("/auth", window.location.origin).href;
export const authClient = createAuthClient({
baseURL,
+141
View File
@@ -8,6 +8,8 @@ import {
OrganizationInvitation,
OrganizationMember,
ProjectMember,
ScheduledJob,
ScheduledJobRun,
WebhookDelivery,
} from "@primora/api-client";
import type {
@@ -32,6 +34,7 @@ import type {
TelemetryStats,
Webhook,
WebhookCreateResponse,
ScheduledJobCreateResponse,
} from "@primora/api-client";
const STORAGE_KEY = "primora_demo_mode";
@@ -485,6 +488,74 @@ const demoDeliveries: WebhookDelivery[] = [
},
];
const demoJobs: ScheduledJob[] = [
{
id: "demo-job-1",
project_id: "demo-project-1",
name: "nightly-rollup",
schedule: "0 3 * * *",
url: "https://api.example.com/jobs/rollup",
payload: { task: "rollup", window: "24h" },
enabled: true,
has_secret: true,
last_run_at: new Date(Date.now() - 7 * 3_600_000).toISOString(),
last_status: ScheduledJob.last_status.SUCCESS,
next_run_at: new Date(Date.now() + 17 * 3_600_000).toISOString(),
created_at: new Date(Date.now() - 14 * 86_400_000).toISOString(),
},
{
id: "demo-job-2",
project_id: "demo-project-1",
name: "healthcheck-ping",
schedule: "@every 15m",
url: "https://status.example.com/ping",
payload: {},
enabled: false,
has_secret: false,
last_run_at: new Date(Date.now() - 3 * 86_400_000).toISOString(),
last_status: ScheduledJob.last_status.FAILED,
created_at: new Date(Date.now() - 30 * 86_400_000).toISOString(),
},
];
const demoJobRuns: Record<string, ScheduledJobRun[]> = {
"demo-job-1": [
{
id: "demo-run-1",
job_id: "demo-job-1",
status: ScheduledJobRun.status.SUCCESS,
triggered_by: ScheduledJobRun.triggered_by.SCHEDULE,
status_code: 200,
duration_ms: 214,
started_at: new Date(Date.now() - 7 * 3_600_000).toISOString(),
finished_at: new Date(Date.now() - 7 * 3_600_000 + 214).toISOString(),
},
{
id: "demo-run-2",
job_id: "demo-job-1",
status: ScheduledJobRun.status.FAILED,
triggered_by: ScheduledJobRun.triggered_by.MANUAL,
status_code: 500,
error: "endpoint returned 500",
duration_ms: 1024,
started_at: new Date(Date.now() - 31 * 3_600_000).toISOString(),
finished_at: new Date(Date.now() - 31 * 3_600_000 + 1024).toISOString(),
},
],
"demo-job-2": [
{
id: "demo-run-3",
job_id: "demo-job-2",
status: ScheduledJobRun.status.FAILED,
triggered_by: ScheduledJobRun.triggered_by.SCHEDULE,
error: "connection refused",
duration_ms: 12,
started_at: new Date(Date.now() - 3 * 86_400_000).toISOString(),
finished_at: new Date(Date.now() - 3 * 86_400_000 + 12).toISOString(),
},
],
};
class DemoService {
private delay(ms = 250) {
return new Promise((resolve) => setTimeout(resolve, ms));
@@ -1233,6 +1304,76 @@ class DemoService {
return delivery;
}
async listScheduledJobs(): Promise<{ items: ScheduledJob[] }> {
await this.delay();
return { items: demoJobs };
}
async createScheduledJob(data: {
requestBody?: { name?: string; schedule?: string; url?: string; secret?: string; payload?: Record<string, unknown>; enabled?: boolean };
}): Promise<ScheduledJobCreateResponse> {
await this.delay();
const generated = !data.requestBody?.secret;
const job: ScheduledJob = {
id: `demo-job-${demoJobs.length + 1}`,
project_id: "demo-project-1",
name: data.requestBody?.name ?? "untitled",
schedule: data.requestBody?.schedule ?? "0 * * * *",
url: data.requestBody?.url ?? "https://example.com",
payload: (data.requestBody?.payload ?? {}) as Record<string, never>,
enabled: data.requestBody?.enabled ?? true,
has_secret: true,
last_status: null,
next_run_at: new Date(Date.now() + 3_600_000).toISOString(),
created_at: new Date().toISOString(),
};
demoJobs.push(job);
return { ...job, secret: generated ? "demo-secret-" + Math.random().toString(36).slice(2, 18) : undefined };
}
async updateScheduledJob(data: { jobId?: string; requestBody?: Partial<ScheduledJob> }): Promise<ScheduledJob> {
await this.delay();
const job = demoJobs.find((j) => j.id === data.jobId);
if (job && data.requestBody) {
Object.assign(job, data.requestBody);
}
return job as ScheduledJob;
}
async deleteScheduledJob(data: { jobId?: string }) {
await this.delay();
const idx = demoJobs.findIndex((j) => j.id === data.jobId);
if (idx >= 0) demoJobs.splice(idx, 1);
return {};
}
async listScheduledJobRuns(data: { jobId?: string; limit?: number }): Promise<{ items: ScheduledJobRun[] }> {
await this.delay();
return { items: (demoJobRuns[data.jobId ?? ""] ?? []).slice(0, data.limit ?? 50) };
}
async runScheduledJob(data: { jobId?: string }): Promise<ScheduledJobRun> {
await this.delay();
const jobID = data.jobId ?? "demo-job-1";
const run: ScheduledJobRun = {
id: `demo-run-${Date.now()}`,
job_id: jobID,
status: ScheduledJobRun.status.SUCCESS,
triggered_by: ScheduledJobRun.triggered_by.MANUAL,
status_code: 200,
duration_ms: Math.floor(Math.random() * 800) + 50,
started_at: new Date().toISOString(),
finished_at: new Date().toISOString(),
};
(demoJobRuns[jobID] ??= []).unshift(run);
const job = demoJobs.find((j) => j.id === jobID);
if (job) {
job.last_run_at = run.started_at;
job.last_status = ScheduledJob.last_status.SUCCESS;
}
return run;
}
async createDeployMarker(data: {
requestBody?: { version?: string; ref?: string; environment?: string; note?: string };
}): Promise<DeployMarker> {
+594
View File
@@ -0,0 +1,594 @@
import { For, Show, createEffect, createSignal, onCleanup } from "solid-js";
import {
AutomationService,
OpenAPI,
type ScheduledJob,
type ScheduledJobRun,
} from "@primora/api-client";
import { fetchApiToken } from "../lib/auth-client";
import { demoService } from "../lib/demo-mode";
import { Badge } from "../components/Badge";
import { Modal } from "../components/Modal";
import { Input, Textarea } from "../components/Input";
import {
IconPlus,
IconRefresh,
IconTrash,
IconPlay,
IconChevronRight,
IconCheck,
IconCopy,
IconZap,
} from "../components/Icons";
interface AutomationPageProps {
projectID?: string;
canManage: boolean;
demoMode: boolean;
}
type Tab = "schedules" | "events";
interface RealtimeEvent {
type: string;
occurred_at: string;
data: Record<string, unknown>;
}
const err = (e: unknown) => (e instanceof Error ? e.message : String(e));
const svc = (demo: boolean) =>
(demo ? (demoService as unknown as typeof AutomationService) : AutomationService);
const fmtTime = (ts?: string | null) => (ts ? new Date(ts).toLocaleString() : "—");
const statusVariant = (s?: string | null) =>
s === "success" ? "success" : s === "failed" ? "error" : s === "running" ? "warning" : "neutral";
const eventVariant = (t?: string) =>
t?.startsWith("document.")
? "primary"
: t?.startsWith("object.")
? "success"
: t === "job.run"
? "warning"
: t === "issue.created"
? "error"
: "neutral";
const DEMO_EVENT_TYPES = ["document.created", "object.created", "job.run", "deploy.marker", "issue.created"];
export function AutomationPage(props: AutomationPageProps) {
const [tab, setTab] = createSignal<Tab>("schedules");
const [jobs, setJobs] = createSignal<ScheduledJob[]>([]);
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 [createdSecret, setCreatedSecret] = createSignal("");
const [loading, setLoading] = createSignal(false);
const [busy, setBusy] = createSignal<string>();
const [message, setMessage] = createSignal("");
const [error, setError] = createSignal("");
const [events, setEvents] = createSignal<RealtimeEvent[]>([]);
const [live, setLive] = createSignal(false);
const [paused, setPaused] = createSignal(false);
const [typeFilter, setTypeFilter] = createSignal("");
const refresh = async () => {
if (!props.projectID) return;
setLoading(true);
setError("");
try {
const res = await svc(props.demoMode).listScheduledJobs({ projectId: props.projectID });
setJobs(res.items ?? []);
} catch (e) {
setError(err(e));
} finally {
setLoading(false);
}
};
const loadRuns = async (jobID: string) => {
if (!props.projectID) return;
try {
const res = await svc(props.demoMode).listScheduledJobRuns({ projectId: props.projectID, jobId: jobID, limit: 50 });
setRuns((c) => ({ ...c, [jobID]: res.items ?? [] }));
} catch {
/* non-fatal */
}
};
const toggleRuns = (jobID: string) => {
if (expandedJob() === jobID) {
setExpandedJob(undefined);
return;
}
setExpandedJob(jobID);
void loadRuns(jobID);
};
createEffect(() => {
if (props.projectID) void refresh();
});
/* Live events — SSE against /realtime/stream; demo mode fakes a feed. */
createEffect(() => {
if (!props.projectID || tab() !== "events") return;
if (props.demoMode) {
let i = 0;
const timer = setInterval(() => {
if (paused()) return;
const type = DEMO_EVENT_TYPES[i++ % DEMO_EVENT_TYPES.length];
const data: Record<string, unknown> =
type === "job.run"
? { job_name: "nightly-rollup", status: "success", duration_ms: 42 + i }
: type.startsWith("document.")
? { collection_id: "demo-col-1", document_id: `doc-${i}`, document: { title: `Note ${i}` } }
: type.startsWith("object.")
? { bucket_id: "demo-bucket-1", object_key: `uploads/file-${i}.png`, size_bytes: 4096 * i }
: type === "issue.created"
? { fingerprint: `fp-${i}`, message: "TypeError: demo failure", severity: "error" }
: { version: `0.7.${i}`, environment: "production" };
setEvents((prev) => [{ type, occurred_at: new Date().toISOString(), data }, ...prev].slice(0, 200));
}, 2500);
setLive(true);
onCleanup(() => {
clearInterval(timer);
setLive(false);
});
return;
}
let es: EventSource | null = null;
let cancelled = false;
void fetchApiToken().then((token) => {
if (cancelled || !token || !props.projectID) return;
const url = `${OpenAPI.BASE}/projects/${props.projectID}/realtime/stream?token=${encodeURIComponent(token)}`;
es = new EventSource(url);
es.addEventListener("event", (m) => {
setLive(true);
if (paused()) return;
try {
const ev = JSON.parse((m as MessageEvent).data) as RealtimeEvent;
setEvents((prev) => [ev, ...prev].slice(0, 200));
} catch {
/* ignore malformed frames */
}
});
es.onerror = () => setLive(false);
es.onopen = () => setLive(true);
});
onCleanup(() => {
cancelled = true;
es?.close();
setLive(false);
});
});
const run = async (key: string, task: () => Promise<unknown>, done?: string) => {
setBusy(key);
setMessage("");
setError("");
try {
await task();
if (done) setMessage(done);
await refresh();
} catch (e) {
setError(err(e));
} finally {
setBusy(undefined);
}
};
const createJob = (e: Event) => {
e.preventDefault();
const form = jobForm();
let payload: Record<string, unknown> | undefined;
if (form.payload.trim()) {
try {
payload = JSON.parse(form.payload) as Record<string, unknown>;
} catch {
setError("Payload must be valid JSON");
return;
}
}
void run("create-job", async () => {
const created = await svc(props.demoMode).createScheduledJob({
projectId: props.projectID!,
requestBody: {
name: form.name.trim(),
schedule: form.schedule.trim(),
url: form.url.trim(),
secret: form.secret.trim() || undefined,
payload,
enabled: form.enabled,
},
});
if (created.secret) setCreatedSecret(created.secret);
setJobForm({ name: "", schedule: "", url: "", payload: "", secret: "", enabled: true });
setShowCreate(false);
}, "Schedule created");
};
const toggleEnabled = (job: ScheduledJob) =>
void run(`toggle-${job.id}`, () =>
svc(props.demoMode).updateScheduledJob({
projectId: props.projectID!,
jobId: job.id,
requestBody: { enabled: !job.enabled },
}),
);
const runNow = (job: ScheduledJob) =>
void run(
`run-${job.id}`,
() =>
svc(props.demoMode).runScheduledJob({ projectId: props.projectID!, jobId: job.id }),
`Run queued for ${job.name}`,
);
const deleteJob = (job: ScheduledJob) =>
void run(
`del-${job.id}`,
() => svc(props.demoMode).deleteScheduledJob({ projectId: props.projectID!, jobId: job.id }),
`Deleted ${job.name}`,
);
const filteredEvents = () => {
const f = typeFilter();
return f ? events().filter((e) => e.type === f) : events();
};
const eventTypes = () => [...new Set(events().map((e) => e.type))];
return (
<div class="page">
<div class="page-header">
<div>
<h1 class="page-title">Automation</h1>
<p class="page-description">Scheduled jobs and the live event stream for this project.</p>
</div>
<button class="btn btn-secondary" onClick={() => void refresh()} disabled={loading()}>
<IconRefresh class="w-4 h-4" />
Refresh
</button>
</div>
<Show when={message()}>
<div class="message message-success mb-4">{message()}</div>
</Show>
<Show when={error()}>
<div class="message message-error mb-4">{error()}</div>
</Show>
<Show when={createdSecret()}>
<div class="message message-warning mb-4">
<div class="flex-1 min-w-0">
<strong>Job signing secret — shown once.</strong>
<code class="block mt-1" style="word-break: break-all">{createdSecret()}</code>
</div>
<button class="btn btn-ghost btn-sm" onClick={() => navigator.clipboard?.writeText(createdSecret())} title="Copy secret">
<IconCopy class="w-4 h-4" />
</button>
<button class="btn btn-ghost btn-sm" onClick={() => setCreatedSecret("")} title="Dismiss">
<IconCheck class="w-4 h-4" />
</button>
</div>
</Show>
<div class="tabs mb-4">
<button class={`tab ${tab() === "schedules" ? "active" : ""}`} onClick={() => setTab("schedules")}>
Schedules
</button>
<button class={`tab ${tab() === "events" ? "active" : ""}`} onClick={() => setTab("events")}>
Live events
</button>
</div>
<Show when={tab() === "schedules"}>
<div class="card card-flush">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">Scheduled jobs</div>
<div class="card-header-description">
Cron-driven signed POSTs to your endpoints. Deliveries carry{" "}
<code>X-Primora-Signature</code> (HMAC-SHA256) — same scheme as webhooks. Missed
occurrences collapse into a single run.
</div>
</div>
<Show when={props.canManage}>
<button class="btn btn-primary btn-sm" onClick={() => setShowCreate(true)}>
<IconPlus class="w-4 h-4" />
New schedule
</button>
</Show>
</div>
<Show
when={jobs().length > 0}
fallback={
<p class="text-text-2 text-sm p-4">
No schedules yet. Add one to ping an endpoint on a cron — backups, rollups, report
generation.
</p>
}
>
<div class="table-container">
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Schedule</th>
<th>Target</th>
<th class="hidden md:table-cell">Next run</th>
<th class="hidden md:table-cell">Last run</th>
<th>Enabled</th>
<th style="text-align:right">Actions</th>
</tr>
</thead>
<tbody>
<For each={jobs()}>
{(job) => (
<>
<tr>
<td class="font-medium">{job.name}</td>
<td>
<code class="text-xs">{job.schedule}</code>
</td>
<td class="text-text-2" style="max-width:14rem;overflow:hidden;text-overflow:ellipsis">
{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">
<Show when={job.last_run_at} fallback={<span class="text-text-2">never</span>}>
<Badge variant={statusVariant(job.last_status)}>{job.last_status}</Badge>{" "}
<span class="text-text-2 text-xs">{fmtTime(job.last_run_at)}</span>
</Show>
</td>
<td>
<Show when={props.canManage} fallback={<Badge variant={job.enabled ? "success" : "neutral"}>{job.enabled ? "on" : "off"}</Badge>}>
<button
class={`badge ${job.enabled ? "badge-success" : ""} cursor-pointer border-0`}
onClick={() => toggleEnabled(job)}
disabled={busy() === `toggle-${job.id}`}
title={job.enabled ? "Disable" : "Enable"}
>
{job.enabled ? "on" : "off"}
</button>
</Show>
</td>
<td style="text-align:right;white-space:nowrap">
<button class="btn btn-ghost btn-sm" onClick={() => toggleRuns(job.id)}>
<IconChevronRight class={`w-4 h-4 ${expandedJob() === job.id ? "rotate-90" : ""}`} />
Runs
</button>
<Show when={props.canManage}>
<button
class="btn btn-ghost btn-sm"
onClick={() => runNow(job)}
disabled={busy() === `run-${job.id}`}
title="Trigger a manual run now"
>
<IconPlay class="w-4 h-4" />
Run
</button>
<button
class="btn btn-ghost btn-sm"
onClick={() => deleteJob(job)}
disabled={busy() === `del-${job.id}`}
>
<IconTrash class="w-4 h-4" />
</button>
</Show>
</td>
</tr>
<Show when={expandedJob() === job.id}>
<tr>
<td colspan={7} style="padding:0">
<div class="p-4" style="background:var(--bg-subtle)">
<div class="text-xs text-text-2 mb-2 font-medium uppercase tracking-wide">
Run history — last {(runs()[job.id] ?? []).length} of 200 kept
</div>
<Show
when={(runs()[job.id] ?? []).length > 0}
fallback={<p class="text-text-2 text-sm">No runs yet.</p>}
>
<table class="table">
<thead>
<tr>
<th>Status</th>
<th>Trigger</th>
<th class="hidden md:table-cell">HTTP</th>
<th class="hidden md:table-cell">Duration</th>
<th>Started</th>
<th>Error</th>
</tr>
</thead>
<tbody>
<For each={runs()[job.id] ?? []}>
{(r) => (
<tr>
<td>
<Badge variant={statusVariant(r.status)}>{r.status}</Badge>
</td>
<td class="text-text-2">{r.triggered_by}</td>
<td class="hidden md:table-cell text-text-2">{r.status_code ?? "—"}</td>
<td class="hidden md:table-cell text-text-2">
{r.duration_ms != null ? `${r.duration_ms} ms` : "—"}
</td>
<td class="text-text-2">{fmtTime(r.started_at)}</td>
<td class="text-text-2" style="max-width:16rem;overflow:hidden;text-overflow:ellipsis">
{r.error || "—"}
</td>
</tr>
)}
</For>
</tbody>
</table>
</Show>
</div>
</td>
</tr>
</Show>
</>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
</Show>
<Show when={tab() === "events"}>
<div class="card card-flush">
<div class="card-header">
<div class="flex-1">
<div class="card-header-title">
Live events{" "}
<Badge variant={live() ? "success" : "neutral"}>{live() ? "live" : "offline"}</Badge>
</div>
<div class="card-header-description">
Domain events as they happen — document and object mutations, new issues, deploy
markers, job runs. Webhooks subscribed to the same types fire in parallel.
</div>
</div>
<div class="flex gap-2">
<button class="btn btn-secondary btn-sm" onClick={() => setPaused(!paused())}>
{paused() ? "Resume" : "Pause"}
</button>
<button class="btn btn-secondary btn-sm" onClick={() => setEvents([])}>
Clear
</button>
</div>
</div>
<Show when={eventTypes().length > 1}>
<div class="flex gap-2 p-4 pb-0 flex-wrap">
<button
class={`badge cursor-pointer border-0 ${typeFilter() === "" ? "badge-primary" : ""}`}
onClick={() => setTypeFilter("")}
>
all
</button>
<For each={eventTypes()}>
{(t) => (
<button
class={`badge cursor-pointer border-0 ${typeFilter() === t ? "badge-primary" : ""}`}
onClick={() => setTypeFilter(typeFilter() === t ? "" : t)}
>
{t}
</button>
)}
</For>
</div>
</Show>
<Show
when={filteredEvents().length > 0}
fallback={
<p class="text-text-2 text-sm p-4">
{live()
? "Listening — create a document, upload a file, or run a job and it lands here."
: "Connecting to the event stream…"}
</p>
}
>
<div class="table-container">
<table class="table">
<tbody>
<For each={filteredEvents()}>
{(ev) => (
<tr>
<td style="width:11rem">
<Badge variant={eventVariant(ev.type)}>{ev.type}</Badge>
</td>
<td class="text-text-2 text-xs" style="width:11rem">
{fmtTime(ev.occurred_at)}
</td>
<td>
<code
class="text-xs block"
style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:36rem"
title={JSON.stringify(ev.data, null, 2)}
>
{JSON.stringify(ev.data)}
</code>
</td>
</tr>
)}
</For>
</tbody>
</table>
</div>
</Show>
</div>
</Show>
<Modal open={showCreate()} onClose={() => setShowCreate(false)} title="New schedule" size="md">
<form onSubmit={createJob} class="space-y-4">
<Input
label="Name"
value={jobForm().name}
onInput={(e) => setJobForm((c) => ({ ...c, name: e.currentTarget.value }))}
placeholder="nightly-rollup"
required
/>
<div>
<Input
label="Schedule"
value={jobForm().schedule}
onInput={(e) => setJobForm((c) => ({ ...c, schedule: e.currentTarget.value }))}
placeholder="*/15 * * * * or @every 1h"
required
/>
<p class="label-hint">
Standard 5-field cron, or a descriptor: <code>@hourly</code>, <code>@daily</code>,{" "}
<code>@every 30m</code>.
</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>
</div>
<Textarea
label="Payload (optional JSON)"
class="font-mono text-xs"
rows={3}
value={jobForm().payload}
onInput={(e) => setJobForm((c) => ({ ...c, payload: e.currentTarget.value }))}
placeholder='{"task": "rollup", "window": "24h"}'
/>
<Input
label="Signing secret (optional)"
value={jobForm().secret}
onInput={(e) => setJobForm((c) => ({ ...c, secret: e.currentTarget.value }))}
placeholder="generated if empty"
/>
<label class="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={jobForm().enabled}
onChange={(e) => setJobForm((c) => ({ ...c, enabled: e.currentTarget.checked }))}
/>
Enabled — start running on schedule immediately
</label>
<div class="flex gap-2 justify-end">
<button type="button" class="btn btn-secondary" onClick={() => setShowCreate(false)}>
Cancel
</button>
<button type="submit" class="btn btn-primary" disabled={busy() === "create-job"}>
<IconZap class="w-4 h-4" />
Create schedule
</button>
</div>
</form>
</Modal>
</div>
);
}
+1
View File
@@ -7,6 +7,7 @@ export { CollectionsPage } from "./CollectionsPage";
export { DatabasesPage } from "./DatabasesPage";
export { TelemetryPage } from "./TelemetryPage";
export { IntegrationsPage } from "./IntegrationsPage";
export { AutomationPage } from "./AutomationPage";
export { SettingsPage } from "./SettingsPage";
export { AuditPage } from "./AuditPage";
export { AuthPage, type AuthUser } from "./AuthPage";