page.tsx332 lines · main
1import { revalidatePath } from 'next/cache';
2
3import { apiFetch, apiJson } from '../../../../../../lib/api';
4import { CronExpressionField } from './cron-expression-field';
5
6function publicApiOrigin(): string {
7 return process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? '';
8}
9
10interface Schedule {
11 id: string;
12 name: string;
13 functionName: string;
14 cronExpression: string;
15 args: Record<string, unknown>;
16 enabled: boolean;
17 nextRunAt: string;
18 lastRunAt: string | null;
19 lastRunStatus: 'pending' | 'ok' | 'error' | 'skipped' | null;
20 lastRunError: string | null;
21 createdAt: string;
22}
23
24interface FunctionNames {
25 names: string[];
26}
27
28export const dynamic = 'force-dynamic';
29
30const COMMON_EXPRESSIONS: { label: string; expression: string }[] = [
31 { label: 'every minute', expression: '* * * * *' },
32 { label: 'every 5 minutes', expression: '*/5 * * * *' },
33 { label: 'hourly', expression: '@hourly' },
34 { label: 'every day at 04:00 UTC', expression: '0 4 * * *' },
35 { label: 'every monday at 09:00 UTC', expression: '0 9 * * 1' },
36 { label: 'first of month at midnight', expression: '@monthly' },
37];
38
39export default async function CronPage({ params }: { params: Promise<{ id: string }> }) {
40 const { id } = await params;
41
42 const [schedulesResult, fnNamesResult] = await Promise.all([
43 apiJson<{ schedules: Schedule[] }>(`/v1/projects/${id}/schedules`).catch(() => ({
44 schedules: [] as Schedule[],
45 })),
46 apiJson<FunctionNames>(`/v1/projects/${id}/function-names`).catch(() => ({
47 names: [] as string[],
48 })),
49 ]);
50 const schedules = schedulesResult.schedules;
51 const functionNames = fnNamesResult.names;
52
53 async function create(formData: FormData) {
54 'use server';
55 const name = String(formData.get('name') ?? '').trim();
56 const functionName = String(formData.get('functionName') ?? '').trim();
57 const cronExpression = String(formData.get('cronExpression') ?? '').trim();
58 const argsRaw = String(formData.get('args') ?? '').trim();
59
60 let args: Record<string, unknown> = {};
61 if (argsRaw.length > 0) {
62 try {
63 const parsed = JSON.parse(argsRaw);
64 if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
65 args = parsed as Record<string, unknown>;
66 } else {
67 throw new Error('args must be a JSON object');
68 }
69 } catch (err) {
70 throw new Error(`invalid args JSON: ${err instanceof Error ? err.message : 'parse error'}`);
71 }
72 }
73
74 const res = await apiFetch(`/v1/projects/${id}/schedules`, {
75 method: 'POST',
76 headers: { 'content-type': 'application/json' },
77 body: JSON.stringify({ name, functionName, cronExpression, args }),
78 });
79 if (!res.ok) {
80 const body = await res.text().catch(() => '');
81 throw new Error(body || `create failed: ${res.status}`);
82 }
83 revalidatePath(`/dashboard/projects/${id}/cron`);
84 }
85
86 async function toggle(formData: FormData) {
87 'use server';
88 const scheduleId = String(formData.get('scheduleId') ?? '');
89 const enabled = String(formData.get('enabled') ?? '') === 'true';
90 const res = await apiFetch(`/v1/projects/${id}/schedules/${scheduleId}`, {
91 method: 'PATCH',
92 headers: { 'content-type': 'application/json' },
93 body: JSON.stringify({ enabled }),
94 });
95 if (!res.ok) {
96 const body = await res.text().catch(() => '');
97 throw new Error(body || `toggle failed: ${res.status}`);
98 }
99 revalidatePath(`/dashboard/projects/${id}/cron`);
100 }
101
102 async function remove(formData: FormData) {
103 'use server';
104 const scheduleId = String(formData.get('scheduleId') ?? '');
105 const res = await apiFetch(`/v1/projects/${id}/schedules/${scheduleId}`, {
106 method: 'DELETE',
107 });
108 if (!res.ok) throw new Error(`delete failed: ${res.status}`);
109 revalidatePath(`/dashboard/projects/${id}/cron`);
110 }
111
112 return (
113 <div className="flex flex-col gap-6">
114 <header>
115 <h2 className="font-mono text-sm text-[var(--color-text)]">scheduled functions</h2>
116 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
117 cron-triggered invocations. utc only — 5-field expressions plus the standard{' '}
118 <code>@hourly</code> / <code>@daily</code> / <code>@weekly</code> /{' '}
119 <code>@monthly</code> aliases. the dispatcher fires every minute.
120 </p>
121 </header>
122
123 <section className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-5">
124 <h3 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
125 new schedule
126 </h3>
127 <form action={create} className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
128 <label className="flex flex-col gap-1">
129 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
130 name
131 </span>
132 <input
133 required
134 name="name"
135 pattern="[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}"
136 placeholder="nightly-cleanup"
137 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none"
138 />
139 </label>
140
141 <label className="flex flex-col gap-1">
142 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
143 function
144 </span>
145 {functionNames.length > 0 ? (
146 <select
147 required
148 name="functionName"
149 defaultValue=""
150 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none"
151 >
152 <option value="" disabled>
153 pick a deployed function…
154 </option>
155 {functionNames.map((fn) => (
156 <option key={fn} value={fn}>
157 {fn}
158 </option>
159 ))}
160 </select>
161 ) : (
162 <input
163 required
164 name="functionName"
165 placeholder="cleanupExpiredSessions"
166 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none"
167 />
168 )}
169 </label>
170
171 <label className="flex flex-col gap-1 md:col-span-2">
172 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
173 cron expression (utc)
174 </span>
175 <CronExpressionField projectId={id} apiOrigin={publicApiOrigin()} />
176 <CommonExpressionsHint />
177 </label>
178
179 <label className="flex flex-col gap-1 md:col-span-2">
180 <span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
181 args (json object — optional)
182 </span>
183 <textarea
184 name="args"
185 rows={3}
186 placeholder='{"olderThan": "7d"}'
187 className="rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 font-mono text-sm text-[var(--color-text)] focus:border-[var(--color-primary)] focus:outline-none"
188 />
189 </label>
190
191 <div className="md:col-span-2">
192 <button
193 type="submit"
194 className="inline-flex h-9 items-center justify-center rounded-md bg-[var(--color-primary)] px-4 font-sans text-sm text-[var(--color-text-inverse)] transition-colors hover:bg-[var(--color-primary-hover)]"
195 >
196 create schedule
197 </button>
198 </div>
199 </form>
200
201 {functionNames.length === 0 ? (
202 <p className="mt-4 font-mono text-xs text-[var(--color-warning)]">
203 no deployed functions detected. deploy the project once before creating a schedule so
204 the dispatcher has something to call.
205 </p>
206 ) : null}
207 </section>
208
209 <section className="flex flex-col gap-3">
210 <h3 className="font-mono text-xs uppercase tracking-wider text-[var(--color-text-subtle)]">
211 existing schedules
212 </h3>
213 {schedules.length === 0 ? (
214 <p className="rounded-md border border-dashed border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6 text-center font-mono text-sm text-[var(--color-text-muted)]">
215 no schedules yet.
216 </p>
217 ) : (
218 <ul className="flex flex-col gap-2">
219 {schedules.map((s) => (
220 <li
221 key={s.id}
222 className="flex flex-col gap-3 rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] px-4 py-3 sm:flex-row sm:items-center sm:justify-between"
223 >
224 <div className="min-w-0 flex-1">
225 <div className="flex flex-wrap items-baseline gap-2">
226 <span className="font-mono text-sm text-[var(--color-text)]">{s.name}</span>
227 <StatusPill enabled={s.enabled} status={s.lastRunStatus} />
228 </div>
229 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
230 <code>{s.cronExpression}</code> · calls <code>{s.functionName}</code>
231 </p>
232 <p className="mt-0.5 font-mono text-[10px] text-[var(--color-text-subtle)]">
233 next: {formatTimestamp(s.nextRunAt)}
234 {s.lastRunAt ? ` · last: ${formatTimestamp(s.lastRunAt)}` : ''}
235 </p>
236 {s.lastRunError ? (
237 <p className="mt-1 font-mono text-[10px] text-[var(--color-error)]">
238 last error: {s.lastRunError}
239 </p>
240 ) : null}
241 </div>
242 <div className="flex items-center gap-2">
243 <form action={toggle}>
244 <input type="hidden" name="scheduleId" value={s.id} />
245 <input type="hidden" name="enabled" value={(!s.enabled).toString()} />
246 <button
247 type="submit"
248 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-border-strong)] hover:text-[var(--color-text)]"
249 >
250 {s.enabled ? 'pause' : 'resume'}
251 </button>
252 </form>
253 <form action={remove}>
254 <input type="hidden" name="scheduleId" value={s.id} />
255 <button
256 type="submit"
257 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-error)] hover:text-[var(--color-error)]"
258 >
259 delete
260 </button>
261 </form>
262 </div>
263 </li>
264 ))}
265 </ul>
266 )}
267 </section>
268 </div>
269 );
270}
271
272function CommonExpressionsHint() {
273 return (
274 <p className="mt-1 font-mono text-[10px] text-[var(--color-text-subtle)]">
275 common patterns:{' '}
276 {COMMON_EXPRESSIONS.map((c, i) => (
277 <span key={c.expression}>
278 {i > 0 ? ' · ' : ''}
279 <code className="text-[var(--color-text-muted)]">{c.expression}</code>{' '}
280 <span className="text-[var(--color-text-subtle)]">({c.label})</span>
281 </span>
282 ))}
283 </p>
284 );
285}
286
287function StatusPill({
288 enabled,
289 status,
290}: {
291 enabled: boolean;
292 status: Schedule['lastRunStatus'];
293}) {
294 if (!enabled) {
295 return (
296 <span className="rounded-full border border-[var(--color-border-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-text-subtle)]">
297 paused
298 </span>
299 );
300 }
301 if (!status || status === 'pending') {
302 return (
303 <span className="rounded-full border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-primary)]">
304 active
305 </span>
306 );
307 }
308 if (status === 'ok') {
309 return (
310 <span className="rounded-full border border-[var(--color-border-primary)] bg-[var(--color-primary-subtle)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-primary)]">
311 last ok
312 </span>
313 );
314 }
315 if (status === 'error') {
316 return (
317 <span className="rounded-full border border-[var(--color-error)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-error)]">
318 last error
319 </span>
320 );
321 }
322 return (
323 <span className="rounded-full border border-[var(--color-warning)] px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-[var(--color-warning)]">
324 skipped
325 </span>
326 );
327}
328
329function formatTimestamp(iso: string): string {
330 const d = new Date(iso);
331 return d.toISOString().replace('T', ' ').slice(0, 16) + ' utc';
332}