page.tsx332 lines · main
| 1 | import { revalidatePath } from 'next/cache'; |
| 2 | |
| 3 | import { apiFetch, apiJson } from '../../../../../../lib/api'; |
| 4 | import { CronExpressionField } from './cron-expression-field'; |
| 5 | |
| 6 | function publicApiOrigin(): string { |
| 7 | return process.env.NEXT_PUBLIC_BRIVEN_API_ORIGIN ?? ''; |
| 8 | } |
| 9 | |
| 10 | interface 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 | |
| 24 | interface FunctionNames { |
| 25 | names: string[]; |
| 26 | } |
| 27 | |
| 28 | export const dynamic = 'force-dynamic'; |
| 29 | |
| 30 | const 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 | |
| 39 | export 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 | |
| 272 | function 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 | |
| 287 | function 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 | |
| 329 | function formatTimestamp(iso: string): string { |
| 330 | const d = new Date(iso); |
| 331 | return d.toISOString().replace('T', ' ').slice(0, 16) + ' utc'; |
| 332 | } |