mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
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:
1 parent
4b7fdf35ad
commit
9e9a3d5f98
49 files changed
+3555
-32
No files matched your search
@@ -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()}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
Reference in new issue
Block a user