Skip to content

Commit 9b3aa29

Browse files
committed
feat: paginate webhook run history
1 parent c1cc489 commit 9b3aa29

7 files changed

Lines changed: 179 additions & 13 deletions

File tree

opencode/packages/opencode/src/server/routes/webhooks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,7 @@ export const WebhookRoutes = lazy(() =>
778778
z.object({
779779
sourceID: z.string().optional(),
780780
limit: z.coerce.number().min(1).max(500).optional(),
781+
offset: z.coerce.number().int().min(0).optional(),
781782
}),
782783
),
783784
async (c) => c.json(await Webhook.listRuns(c.req.valid("query"))),

opencode/packages/opencode/src/webhook/webhook.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -391,7 +391,7 @@ export namespace Webhook {
391391
})
392392
}
393393

394-
export async function listRuns(input: { sourceID?: string; limit?: number } = {}) {
394+
export async function listRuns(input: { sourceID?: string; limit?: number; offset?: number } = {}) {
395395
const runs: Run[] = []
396396
for (const key of await Storage.list(["webhook_run"])) {
397397
const run = await Storage.read<Run>(key).catch(() => undefined)
@@ -401,7 +401,8 @@ export namespace Webhook {
401401
runs.push(parsed)
402402
}
403403
runs.sort((a, b) => b.time.received - a.time.received)
404-
return runs.slice(0, input.limit ?? 100)
404+
const offset = Math.max(0, input.offset ?? 0)
405+
return runs.slice(offset, offset + (input.limit ?? 100))
405406
}
406407

407408
export async function withSourceLock<T>(sourceID: string, fn: () => Promise<T>) {

opencode/packages/opencode/test/server/webhooks-test-route.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,42 @@ describe("webhook management test route", () => {
5757
}
5858
}
5959
})
60+
61+
test("applies source-scoped offset pagination to run records", async () => {
62+
await using tmp = await tmpdir({ git: true })
63+
const sourceID = `src_page_${Math.random().toString(36).slice(2)}`
64+
const runIDs: string[] = []
65+
let projectID: string | undefined
66+
67+
try {
68+
await Instance.provide({
69+
directory: tmp.path,
70+
fn: async () => {
71+
projectID = Instance.project.id
72+
const oldest = await Webhook.createRun({ sourceID, projectID, status: "succeeded" })
73+
const middle = await Webhook.createRun({ sourceID, projectID, status: "failed" })
74+
const newest = await Webhook.createRun({ sourceID, projectID, status: "running" })
75+
runIDs.push(oldest.id, middle.id, newest.id)
76+
await Webhook.updateRun(oldest.id, { time: { received: 1_000 } })
77+
await Webhook.updateRun(middle.id, { time: { received: 2_000 } })
78+
await Webhook.updateRun(newest.id, { time: { received: 3_000 } })
79+
80+
const response = await WebhookRoutes().request(
81+
`http://localhost/runs?sourceID=${encodeURIComponent(sourceID)}&limit=1&offset=1`,
82+
)
83+
84+
expect(response.status).toBe(200)
85+
await expect(response.json()).resolves.toEqual([
86+
expect.objectContaining({ id: middle.id }),
87+
])
88+
},
89+
})
90+
} finally {
91+
for (const runID of runIDs) await Storage.remove(["webhook_run", runID])
92+
if (projectID) {
93+
await Storage.remove(["project", projectID])
94+
await Storage.remove(["project_meta", projectID])
95+
}
96+
}
97+
})
6098
})

web/src/api/client.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1743,10 +1743,11 @@ export const webhookApi = {
17431743
return res.json()
17441744
},
17451745

1746-
async runs(opts: { sourceID?: string; limit?: number } = {}): Promise<WebhookRun[]> {
1746+
async runs(opts: { sourceID?: string; limit?: number; offset?: number } = {}): Promise<WebhookRun[]> {
17471747
const params = new URLSearchParams()
17481748
if (opts.sourceID) params.set('sourceID', opts.sourceID)
17491749
if (opts.limit !== undefined) params.set('limit', String(opts.limit))
1750+
if (opts.offset !== undefined) params.set('offset', String(opts.offset))
17501751
const suffix = params.toString() ? `?${params}` : ''
17511752
const res = await fetchWithTimeout(`${BASE_URL}/webhooks/runs${suffix}`)
17521753
if (!res.ok) {

web/src/components/WebhooksPage.vue

Lines changed: 111 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const emit = defineEmits<{
5151
}>()
5252
5353
const DEFAULT_PRESET = webhookPresetById('generic')
54+
const RUN_PAGE_SIZE = 10
5455
const helpText = {
5556
guards: 'Guards reject noisy, duplicate, or stale webhook requests before they create an agent session.',
5657
rateLimit: 'Limits how many requests this source accepts within one time window.',
@@ -85,17 +86,18 @@ const fullPermissionConfirmed = ref(false)
8586
const defaultModelLabel = ref('Default model from user config')
8687
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null)
8788
const selectedRunId = ref('')
89+
const runPage = ref(1)
90+
const runPageItems = ref<WebhookRun[]>([])
91+
const runPageHasNext = ref(false)
92+
const isRunPageLoading = ref(false)
8893
const endpointPanel = ref<HTMLElement | null>(null)
94+
let runPageLoadGeneration = 0
8995
9096
const form = ref(defaultForm())
9197
9298
const isEmbedded = computed(() => props.embedded)
9399
const selectedSource = computed(() => sources.value.find((source) => source.id === selectedSourceId.value) || null)
94-
const selectedRuns = computed(() => {
95-
if (!selectedSourceId.value) return runs.value
96-
return runs.value.filter((run) => run.sourceID === selectedSourceId.value)
97-
})
98-
const selectedRun = computed(() => selectedRuns.value.find((run) => run.id === selectedRunId.value) || null)
100+
const selectedRun = computed(() => runPageItems.value.find((run) => run.id === selectedRunId.value) || null)
99101
100102
const sortedProjects = computed(() => props.projects.slice().sort((a, b) => b.time.updated - a.time.updated))
101103
const selectedProvider = computed(() => providers.value.find((provider) => provider.id === form.value.modelProviderID))
@@ -250,9 +252,11 @@ async function loadAll() {
250252
}
251253
if (!selectedSourceId.value) {
252254
resetCreateForm()
255+
resetRunPagination()
253256
showCreateForm.value = true
254257
} else {
255258
loadFormFromSource(selectedSource.value)
259+
await refreshRunPage()
256260
}
257261
} catch (err) {
258262
error.value = friendlyError(err)
@@ -347,6 +351,7 @@ async function createSource() {
347351
showCreateForm.value = false
348352
revealedSecretSourceId.value = created.source.id
349353
revealedSecret.value = created.secret
354+
resetRunPagination()
350355
notice.value = 'Webhook source created. Copy the full URL now; the secret is shown only once.'
351356
await refreshRuns()
352357
await nextTick()
@@ -400,6 +405,7 @@ async function sendTest() {
400405
try {
401406
const result = await webhookApi.sendTest(source.id, payload)
402407
notice.value = `Test sent. HTTP ${result.status}.`
408+
resetRunPagination()
403409
await refreshRuns()
404410
if (result.body && typeof result.body === 'object' && 'runId' in result.body) {
405411
selectedRunId.value = String((result.body as { runId?: unknown }).runId || '')
@@ -448,6 +454,7 @@ async function deleteSource() {
448454
await webhookApi.deleteSource(source.id)
449455
sources.value = await webhookApi.sources()
450456
selectedSourceId.value = sources.value[0]?.id || ''
457+
resetRunPagination()
451458
if (!selectedSourceId.value) {
452459
showCreateForm.value = true
453460
resetCreateForm()
@@ -462,7 +469,71 @@ async function deleteSource() {
462469
}
463470
464471
async function refreshRuns() {
465-
runs.value = await webhookApi.runs({ limit: 100 })
472+
const [nextRuns] = await Promise.all([
473+
webhookApi.runs({ limit: 100 }),
474+
refreshRunPage(),
475+
])
476+
runs.value = nextRuns
477+
}
478+
479+
function resetRunPagination() {
480+
runPage.value = 1
481+
runPageItems.value = []
482+
runPageHasNext.value = false
483+
selectedRunId.value = ''
484+
}
485+
486+
async function refreshRunPage() {
487+
const sourceID = selectedSourceId.value
488+
const page = runPage.value
489+
const generation = ++runPageLoadGeneration
490+
if (!sourceID) {
491+
runPageItems.value = []
492+
runPageHasNext.value = false
493+
isRunPageLoading.value = false
494+
return
495+
}
496+
497+
isRunPageLoading.value = true
498+
try {
499+
const pageRuns = await webhookApi.runs({
500+
sourceID,
501+
limit: RUN_PAGE_SIZE + 1,
502+
offset: (page - 1) * RUN_PAGE_SIZE,
503+
})
504+
if (generation !== runPageLoadGeneration || sourceID !== selectedSourceId.value || page !== runPage.value) return
505+
runPageItems.value = pageRuns.slice(0, RUN_PAGE_SIZE)
506+
runPageHasNext.value = pageRuns.length > RUN_PAGE_SIZE
507+
if (selectedRunId.value && !runPageItems.value.some((run) => run.id === selectedRunId.value)) {
508+
selectedRunId.value = ''
509+
}
510+
} finally {
511+
if (generation === runPageLoadGeneration) {
512+
isRunPageLoading.value = false
513+
}
514+
}
515+
}
516+
517+
async function previousRunPage() {
518+
if (runPage.value <= 1 || isRunPageLoading.value) return
519+
runPage.value -= 1
520+
selectedRunId.value = ''
521+
try {
522+
await refreshRunPage()
523+
} catch (err) {
524+
error.value = friendlyError(err)
525+
}
526+
}
527+
528+
async function nextRunPage() {
529+
if (!runPageHasNext.value || isRunPageLoading.value) return
530+
runPage.value += 1
531+
selectedRunId.value = ''
532+
try {
533+
await refreshRunPage()
534+
} catch (err) {
535+
error.value = friendlyError(err)
536+
}
466537
}
467538
468539
async function copyText(text: string) {
@@ -499,7 +570,7 @@ async function openRunSession(run: WebhookRun) {
499570
function beginCreate() {
500571
showCreateForm.value = true
501572
selectedSourceId.value = ''
502-
selectedRunId.value = ''
573+
resetRunPagination()
503574
revealedSecret.value = ''
504575
revealedSecretSourceId.value = ''
505576
resetCreateForm()
@@ -508,8 +579,11 @@ function beginCreate() {
508579
function selectSource(source: WebhookSource) {
509580
showCreateForm.value = false
510581
selectedSourceId.value = source.id
511-
selectedRunId.value = ''
582+
resetRunPagination()
512583
showMcpPicker.value = false
584+
void refreshRunPage().catch((err) => {
585+
error.value = friendlyError(err)
586+
})
513587
}
514588
515589
function selectRun(run: WebhookRun) {
@@ -1073,7 +1147,7 @@ onUnmounted(() => {
10731147
<h3>Recent Runs</h3>
10741148
<div class="run-list">
10751149
<div
1076-
v-for="run in selectedRuns"
1150+
v-for="run in runPageItems"
10771151
:key="run.id"
10781152
class="run-row"
10791153
:class="{ active: selectedRunId === run.id }"
@@ -1086,10 +1160,22 @@ onUnmounted(() => {
10861160
<button v-if="run.sessionID" class="link-btn" @click.stop="openRunSession(run)">Open session</button>
10871161
<span v-else class="muted-text">{{ run.guardType || 'No session' }}</span>
10881162
</div>
1089-
<div v-if="selectedRuns.length === 0" class="empty-note">
1163+
<div v-if="isRunPageLoading && runPageItems.length === 0" class="empty-note">
1164+
Loading runs...
1165+
</div>
1166+
<div v-else-if="runPageItems.length === 0" class="empty-note">
10901167
No runs for this source yet.
10911168
</div>
10921169
</div>
1170+
<div v-if="runPageItems.length > 0 || runPage > 1" class="run-pagination">
1171+
<button class="btn" @click="previousRunPage" :disabled="runPage <= 1 || isRunPageLoading">
1172+
Previous
1173+
</button>
1174+
<span>Page {{ runPage }}</span>
1175+
<button class="btn" @click="nextRunPage" :disabled="!runPageHasNext || isRunPageLoading">
1176+
Next
1177+
</button>
1178+
</div>
10931179
<div v-if="selectedRun" class="run-detail">
10941180
<div class="run-detail-head">
10951181
<strong>{{ selectedRun.id }}</strong>
@@ -1854,6 +1940,21 @@ textarea {
18541940
color: var(--text-muted);
18551941
}
18561942
1943+
.run-pagination {
1944+
display: flex;
1945+
align-items: center;
1946+
justify-content: flex-end;
1947+
gap: var(--space-sm);
1948+
margin-top: var(--space-md);
1949+
color: var(--text-muted);
1950+
font-size: 12px;
1951+
}
1952+
1953+
.run-pagination .btn {
1954+
height: 32px;
1955+
padding: 0 var(--space-sm);
1956+
}
1957+
18571958
.link-btn {
18581959
border: 0;
18591960
background: transparent;

web/test/config-api.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@ afterEach(() => {
6464
})
6565

6666
describe('web config APIs', () => {
67+
it('passes source, limit, and offset when paging webhook runs', async () => {
68+
installFetchMock((url) => {
69+
if (url === '/webhooks/runs?sourceID=src%2Ftest&limit=11&offset=20') {
70+
return jsonResponse([{ id: 'run_21' }])
71+
}
72+
throw new Error(`Unexpected request: ${url}`)
73+
})
74+
75+
await expect(webhookApi.runs({ sourceID: 'src/test', limit: 11, offset: 20 })).resolves.toEqual([
76+
{ id: 'run_21' },
77+
])
78+
})
79+
6780
it('sends webhook tests through the same-origin management endpoint', async () => {
6881
installFetchMock((url) => {
6982
if (url === '/webhooks/sources/src%2Ftest/test') {

web/test/webhook-send-test-ui.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,15 @@ describe('webhook send test controls', () => {
88
expect(button?.[1]).toBe('isSaving || isSendingTest')
99
expect(source).not.toContain('Send test requires the one-time full URL')
1010
})
11+
12+
it('renders a ten-item server-backed pager for run records', async () => {
13+
const source = await Bun.file(new URL('../src/components/WebhooksPage.vue', import.meta.url)).text()
14+
15+
expect(source).toContain('const RUN_PAGE_SIZE = 10')
16+
expect(source).toContain('v-for="run in runPageItems"')
17+
expect(source).toContain('@click="previousRunPage"')
18+
expect(source).toContain('@click="nextRunPage"')
19+
expect(source).toContain('Page {{ runPage }}')
20+
expect(source).not.toContain('v-for="run in selectedRuns"')
21+
})
1122
})

0 commit comments

Comments
 (0)