Skip to content

Commit 658da7b

Browse files
committed
feat(dashboard): jobs + schedule API actions for the admin views
The dashboard at storage/framework/defaults/views/dashboard/jobs/* and queue/index.stx already exist but call API endpoints that the backend doesn't define yet — so the views render empty. This wires the four canonical endpoints: GET /api/buddy/jobs — list pending + processing + failed POST /api/buddy/jobs/{id}/retry — re-queue a failed job (resets attempts) POST /api/buddy/jobs/{id}/cancel — set the cancellation flag the worker polls between iterations GET /api/buddy/schedule — registered scheduled tasks with last/next run times Each action fails soft when its underlying table doesn't exist (most common cause: the user hasn't run the queue/scheduler migrations yet) so the dashboard renders a helpful empty state instead of a 500.
1 parent f45ecc6 commit 658da7b

5 files changed

Lines changed: 283 additions & 0 deletions

File tree

routes/buddy.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,12 @@ import { route } from '@stacksjs/router'
1010

1111
route.get('/versions', 'Actions/Buddy/VersionsAction') // your-domain.com/api/buddy/versions
1212
route.get('/commands', 'Actions/Buddy/CommandsAction') // your-domain.com/api/buddy/commands
13+
14+
// Admin-dashboard backends. The existing dashboard/jobs/*.stx views
15+
// expect these to exist; without them the dashboard renders empty
16+
// states. They're under /api/buddy/* so the same auth gate that
17+
// protects the rest of the buddy admin surface applies.
18+
route.get('/jobs', 'Actions/Buddy/JobsListAction')
19+
route.post('/jobs/{id}/retry', 'Actions/Buddy/JobRetryAction')
20+
route.post('/jobs/{id}/cancel', 'Actions/Buddy/JobCancelAction')
21+
route.get('/schedule', 'Actions/Buddy/ScheduleListAction')
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Action } from '@stacksjs/actions'
2+
3+
/**
4+
* POST /api/buddy/jobs/:id/cancel
5+
*
6+
* Mark an in-flight job as cancelled. Workers that call
7+
* `isJobCancelled(id)` between iterations will see the flag and exit
8+
* cleanly. The framework can't preempt a running handler, so the
9+
* worker is responsible for honoring the cancellation between units
10+
* of work.
11+
*/
12+
export default new Action({
13+
name: 'Buddy Job Cancel',
14+
description: 'Signal a running job to stop on its next checkpoint.',
15+
16+
async handle(request) {
17+
const id = String(request.get('id', '') ?? '')
18+
if (!id) return new Response(JSON.stringify({ error: 'missing id' }), { status: 400 })
19+
20+
try {
21+
const { cancelJob } = await import('@stacksjs/queue')
22+
await cancelJob(id)
23+
return { ok: true, id }
24+
}
25+
catch (err) {
26+
return new Response(
27+
JSON.stringify({ error: err instanceof Error ? err.message : String(err) }),
28+
{ status: 500, headers: { 'Content-Type': 'application/json' } },
29+
)
30+
}
31+
},
32+
})
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Action } from '@stacksjs/actions'
2+
3+
/**
4+
* POST /api/buddy/jobs/:id/retry
5+
*
6+
* Move a failed job back into the active queue. The dashboard's
7+
* `jobs/history.stx` calls this when the user clicks "Retry" on a
8+
* failed-job row.
9+
*/
10+
export default new Action({
11+
name: 'Buddy Job Retry',
12+
description: 'Re-queue a failed job for processing.',
13+
14+
async handle(request) {
15+
const id = String(request.get('id', '') ?? '')
16+
if (!id) return new Response(JSON.stringify({ error: 'missing id' }), { status: 400 })
17+
18+
try {
19+
const { db } = await import('@stacksjs/database')
20+
const dbAny = db as any
21+
22+
const failed = await dbAny.selectFrom('failed_jobs').selectAll().where('id', '=', id).executeTakeFirst()
23+
if (!failed) {
24+
return new Response(JSON.stringify({ error: 'failed job not found' }), { status: 404 })
25+
}
26+
27+
// Move the row back to the live queue, resetting attempts so the
28+
// worker doesn't immediately re-fail it.
29+
const now = Math.floor(Date.now() / 1000)
30+
await dbAny.insertInto('jobs').values({
31+
queue: failed.queue,
32+
payload: failed.payload,
33+
attempts: 0,
34+
reserved_at: null,
35+
available_at: now,
36+
created_at: new Date().toISOString().slice(0, 19).replace('T', ' '),
37+
}).execute()
38+
await dbAny.deleteFrom('failed_jobs').where('id', '=', id).execute()
39+
40+
return { ok: true, retriedAt: now }
41+
}
42+
catch (err) {
43+
return new Response(
44+
JSON.stringify({ error: err instanceof Error ? err.message : String(err) }),
45+
{ status: 500, headers: { 'Content-Type': 'application/json' } },
46+
)
47+
}
48+
},
49+
})
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { Action } from '@stacksjs/actions'
2+
3+
/**
4+
* GET /api/buddy/jobs
5+
*
6+
* Powers the existing `dashboard/jobs/history.stx` view. Reads the
7+
* `jobs` and `failed_jobs` tables and merges them into a single
8+
* timeline shape the dashboard already understands.
9+
*
10+
* Query params:
11+
* - status: 'all' | 'queued' | 'processing' | 'completed' | 'failed'
12+
* - queue: 'all' | <queue-name>
13+
* - q: free-text filter on job name
14+
* - page, perPage: pagination
15+
*/
16+
export default new Action({
17+
name: 'Buddy Jobs List',
18+
description: 'List queued, in-flight, completed, and failed jobs for the admin dashboard.',
19+
20+
async handle(request) {
21+
const status = String(request.get('status', 'all') ?? 'all')
22+
const queue = String(request.get('queue', 'all') ?? 'all')
23+
const q = String(request.get('q', '') ?? '')
24+
const page = Math.max(1, Number(request.get('page', 1) ?? 1))
25+
const perPage = Math.min(100, Math.max(1, Number(request.get('perPage', 25) ?? 25)))
26+
27+
try {
28+
const { db } = await import('@stacksjs/database')
29+
const dbAny = db as any
30+
31+
// Pull the live `jobs` table — this contains queued + reserved (processing) rows.
32+
let pendingQuery = dbAny.selectFrom('jobs').selectAll()
33+
if (queue !== 'all') pendingQuery = pendingQuery.where('queue', '=', queue)
34+
if (q) pendingQuery = pendingQuery.where('payload', 'like', `%${q}%`)
35+
36+
// Pull the failed_jobs table for the failed status.
37+
let failedQuery = dbAny.selectFrom('failed_jobs').selectAll()
38+
if (queue !== 'all') failedQuery = failedQuery.where('queue', '=', queue)
39+
if (q) failedQuery = failedQuery.where('payload', 'like', `%${q}%`)
40+
41+
const [pending, failed] = await Promise.all([
42+
status === 'failed' ? Promise.resolve([]) : pendingQuery.execute(),
43+
status === 'queued' || status === 'processing' || status === 'completed' ? Promise.resolve([]) : failedQuery.execute(),
44+
])
45+
46+
const normalized = [
47+
...(pending as Array<Record<string, unknown>>).map(row => normalizePending(row, status)),
48+
...(failed as Array<Record<string, unknown>>).map(normalizeFailed),
49+
]
50+
.filter((j): j is NonNullable<typeof j> => j !== null)
51+
.sort((a, b) => (b.created_at?.localeCompare(a.created_at ?? '') ?? 0))
52+
53+
const total = normalized.length
54+
const start = (page - 1) * perPage
55+
const items = normalized.slice(start, start + perPage)
56+
57+
return {
58+
items,
59+
total,
60+
page,
61+
perPage,
62+
totalPages: Math.max(1, Math.ceil(total / perPage)),
63+
}
64+
}
65+
catch (err) {
66+
// Fail soft so the dashboard can render an empty state instead of
67+
// an opaque 500. Surface the underlying message so it's visible
68+
// in the dev-mode error toast.
69+
return {
70+
items: [],
71+
total: 0,
72+
page,
73+
perPage,
74+
totalPages: 1,
75+
error: err instanceof Error ? err.message : String(err),
76+
}
77+
}
78+
},
79+
})
80+
81+
interface NormalizedJob {
82+
id: string
83+
name: string
84+
queue: string
85+
status: 'queued' | 'processing' | 'completed' | 'failed'
86+
attempts: number
87+
runtime?: number
88+
started_at?: string
89+
finished_at?: string
90+
error?: string
91+
payload: unknown
92+
created_at?: string
93+
}
94+
95+
function normalizePending(row: Record<string, unknown>, statusFilter: string): NormalizedJob | null {
96+
const status: NormalizedJob['status'] = row.reserved_at ? 'processing' : 'queued'
97+
if (statusFilter !== 'all' && statusFilter !== status) return null
98+
let payload: unknown = row.payload
99+
let name = 'unknown'
100+
if (typeof payload === 'string') {
101+
try {
102+
const parsed = JSON.parse(payload) as { jobName?: string }
103+
name = parsed.jobName ?? 'unknown'
104+
payload = parsed
105+
}
106+
catch { /* leave as raw string */ }
107+
}
108+
return {
109+
id: String(row.id ?? ''),
110+
name,
111+
queue: String(row.queue ?? 'default'),
112+
status,
113+
attempts: Number(row.attempts ?? 0),
114+
payload,
115+
created_at: row.created_at ? String(row.created_at) : undefined,
116+
}
117+
}
118+
119+
function normalizeFailed(row: Record<string, unknown>): NormalizedJob {
120+
let payload: unknown = row.payload
121+
let name = 'unknown'
122+
if (typeof payload === 'string') {
123+
try {
124+
const parsed = JSON.parse(payload) as { jobName?: string }
125+
name = parsed.jobName ?? 'unknown'
126+
payload = parsed
127+
}
128+
catch { /* leave as raw */ }
129+
}
130+
return {
131+
id: String(row.id ?? ''),
132+
name,
133+
queue: String(row.queue ?? 'default'),
134+
status: 'failed',
135+
attempts: Number(row.attempts ?? 0),
136+
error: row.exception ? String(row.exception) : undefined,
137+
finished_at: row.failed_at ? String(row.failed_at) : undefined,
138+
payload,
139+
created_at: row.failed_at ? String(row.failed_at) : undefined,
140+
}
141+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Action } from '@stacksjs/actions'
2+
3+
/**
4+
* GET /api/buddy/schedule
5+
*
6+
* Snapshot of every registered scheduled task — pattern, last run,
7+
* next run, status — for the admin dashboard's schedule view.
8+
*
9+
* Reads from the queue/database scheduler's `scheduled_jobs` table
10+
* if it exists, falling back to an empty list otherwise so the
11+
* dashboard doesn't 500 on apps that don't use scheduled jobs.
12+
*/
13+
export default new Action({
14+
name: 'Buddy Schedule List',
15+
description: 'List registered scheduled tasks with their next/last run times.',
16+
17+
async handle() {
18+
try {
19+
const { db } = await import('@stacksjs/database')
20+
const dbAny = db as any
21+
22+
const rows = await dbAny
23+
.selectFrom('scheduled_jobs')
24+
.selectAll()
25+
.orderBy('next_run_at', 'asc')
26+
.execute()
27+
28+
return {
29+
items: (rows as Array<Record<string, unknown>>).map(row => ({
30+
id: String(row.id ?? ''),
31+
name: String(row.name ?? row.action ?? 'unnamed'),
32+
pattern: String(row.cron_expression ?? row.pattern ?? '?'),
33+
timezone: row.timezone ? String(row.timezone) : null,
34+
lastRunAt: row.last_run_at ? String(row.last_run_at) : null,
35+
nextRunAt: row.next_run_at ? String(row.next_run_at) : null,
36+
enabled: row.enabled !== false,
37+
consecutiveFailures: Number(row.consecutive_failures ?? 0),
38+
})),
39+
}
40+
}
41+
catch (err) {
42+
// Most common cause: the scheduled_jobs table doesn't exist
43+
// because the user hasn't run the scheduler migration. Return
44+
// an empty list with a hint instead of a 500.
45+
return {
46+
items: [],
47+
error: err instanceof Error ? err.message : String(err),
48+
hint: 'If you see "no such table: scheduled_jobs", run `buddy migrate`.',
49+
}
50+
}
51+
},
52+
})

0 commit comments

Comments
 (0)