Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: load more logs

Hugo c99c0e8a e3ed6425

+278 -31
+6
app/islands/DeliveryLog.css.ts
··· 120 120 color: vars.color.text, 121 121 borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 122 122 }); 123 + 124 + export const loadMoreWrapper = style({ 125 + display: "flex", 126 + justifyContent: "center", 127 + paddingBlockStart: space[2], 128 + });
+58 -25
app/islands/DeliveryLog.tsx
··· 1 - import { useState, useCallback } from "hono/jsx"; 1 + import { useState, useCallback, useRef } from "hono/jsx"; 2 2 import * as s from "./DeliveryLog.css.ts"; 3 3 import { variant as badgeVariant } from "../components/Badge/styles.css.ts"; 4 4 ··· 16 16 rkey: string; 17 17 active: boolean; 18 18 initialLogs: LogEntry[]; 19 + hasMore: boolean; 19 20 }; 20 21 21 - export default function DeliveryLog({ rkey, active, initialLogs }: Props) { 22 + export default function DeliveryLog({ rkey, active, initialLogs, hasMore: initialHasMore }: Props) { 22 23 const [isActive, setIsActive] = useState(active); 23 24 const [logs, setLogs] = useState(initialLogs); 24 25 const [loading, setLoading] = useState(false); 26 + const [loadingMore, setLoadingMore] = useState(false); 27 + const [hasMore, setHasMore] = useState(initialHasMore); 25 28 const [error, setError] = useState(""); 29 + const logsRef = useRef(logs); 30 + logsRef.current = logs; 26 31 27 32 const toggleActive = useCallback(async () => { 28 33 setLoading(true); ··· 78 83 if (res.ok) { 79 84 const data = await res.json(); 80 85 setLogs(data.deliveryLogs || []); 86 + setHasMore(data.hasMore ?? false); 81 87 } 82 88 } catch { 83 89 // ignore 84 90 } 85 91 }, [rkey]); 86 92 93 + const loadMore = useCallback(async () => { 94 + const lastId = logsRef.current?.[logsRef.current.length - 1]?.id; 95 + if (!lastId) return; 96 + setLoadingMore(true); 97 + try { 98 + const res = await fetch(`/api/automations/${rkey}/logs?before=${lastId}`); 99 + if (res.ok) { 100 + const data = await res.json(); 101 + setLogs((prev) => [...prev, ...data.logs]); 102 + setHasMore(data.hasMore); 103 + } 104 + } catch { 105 + // ignore 106 + } finally { 107 + setLoadingMore(false); 108 + } 109 + }, [rkey]); 110 + 87 111 return ( 88 112 <div class={s.wrapper}> 89 113 <div class={s.actions}> ··· 107 131 {logs.length === 0 ? ( 108 132 <p class={s.emptyState}>No deliveries yet.</p> 109 133 ) : ( 110 - <div class={s.tableWrapper}> 111 - <table class={s.table}> 112 - <thead> 113 - <tr> 114 - <th class={s.th}>Time</th> 115 - <th class={s.th}>Action</th> 116 - <th class={s.th}>Status</th> 117 - <th class={s.th}>Attempt</th> 118 - <th class={s.th}>Error</th> 119 - </tr> 120 - </thead> 121 - <tbody> 122 - {logs.map((log) => ( 123 - <tr key={log.id}> 124 - <td class={s.td}>{new Date(log.createdAt).toLocaleString()}</td> 125 - <td class={s.td}>{log.actionIndex + 1}</td> 126 - <td class={s.td}>{log.statusCode ?? "\u2014"}</td> 127 - <td class={s.td}>{log.attempt}</td> 128 - <td class={s.td}>{log.error || "\u2014"}</td> 134 + <> 135 + <div class={s.tableWrapper}> 136 + <table class={s.table}> 137 + <thead> 138 + <tr> 139 + <th class={s.th}>Time</th> 140 + <th class={s.th}>Action</th> 141 + <th class={s.th}>Status</th> 142 + <th class={s.th}>Attempt</th> 143 + <th class={s.th}>Error</th> 129 144 </tr> 130 - ))} 131 - </tbody> 132 - </table> 133 - </div> 145 + </thead> 146 + <tbody> 147 + {logs.map((log) => ( 148 + <tr key={log.id}> 149 + <td class={s.td}>{new Date(log.createdAt).toLocaleString()}</td> 150 + <td class={s.td}>{log.actionIndex + 1}</td> 151 + <td class={s.td}>{log.statusCode ?? "\u2014"}</td> 152 + <td class={s.td}>{log.attempt}</td> 153 + <td class={s.td}>{log.error || "\u2014"}</td> 154 + </tr> 155 + ))} 156 + </tbody> 157 + </table> 158 + </div> 159 + {hasMore && ( 160 + <div class={s.loadMoreWrapper}> 161 + <button type="button" class={s.refreshBtn} onClick={loadMore} disabled={loadingMore}> 162 + {loadingMore ? "Loading\u2026" : "Load more"} 163 + </button> 164 + </div> 165 + )} 166 + </> 134 167 )} 135 168 </div> 136 169 );
+22
app/routes/api/automations/[rkey].test.ts
··· 114 114 expect(body.name).toBe("Test Auto"); 115 115 expect(body.deliveryLogs).toHaveLength(1); 116 116 expect(body.deliveryLogs[0].statusCode).toBe(200); 117 + expect(body.hasMore).toBe(false); 118 + }); 119 + 120 + it("returns hasMore true when more than 50 logs exist", async () => { 121 + await db.insert(automations).values(TEST_AUTO); 122 + const logValues = Array.from({ length: 51 }, (_, i) => ({ 123 + automationUri: TEST_AUTO.uri, 124 + actionIndex: 0, 125 + eventTimeUs: 1700000000000000 + i, 126 + payload: null, 127 + statusCode: 200, 128 + error: null, 129 + attempt: 1, 130 + createdAt: new Date(Date.now() - i * 1000), 131 + })); 132 + await db.insert(deliveryLogs).values(logValues); 133 + 134 + const res = await app.request("http://localhost/api/automations/rk1"); 135 + expect(res.status).toBe(200); 136 + const body = await res.json(); 137 + expect(body.deliveryLogs).toHaveLength(50); 138 + expect(body.hasMore).toBe(true); 117 139 }); 118 140 119 141 it("strips webhook secrets from response", async () => {
+6 -3
app/routes/api/automations/[rkey].ts
··· 42 42 const auto = await findAutomation(user.did, rkey); 43 43 if (!auto) return c.json({ error: "Automation not found" }, 404); 44 44 45 - const logs = await db.query.deliveryLogs.findMany({ 45 + const rawLogs = await db.query.deliveryLogs.findMany({ 46 46 where: eq(deliveryLogs.automationUri, auto.uri), 47 - orderBy: desc(deliveryLogs.createdAt), 48 - limit: 50, 47 + orderBy: desc(deliveryLogs.id), 48 + limit: 51, 49 49 }); 50 + const hasMore = rawLogs.length > 50; 51 + const logs = rawLogs.slice(0, 50); 50 52 51 53 return c.json({ 52 54 uri: auto.uri, ··· 68 70 conditions: auto.conditions, 69 71 active: auto.active, 70 72 indexedAt: auto.indexedAt.getTime(), 73 + hasMore, 71 74 deliveryLogs: logs.map((l) => ({ 72 75 id: l.id, 73 76 actionIndex: l.actionIndex,
+138
app/routes/api/automations/[rkey]/logs.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from "vitest"; 2 + import { Hono } from "hono"; 3 + 4 + vi.mock("@/db/index.js", async () => { 5 + const { createTestDb } = await import("@/test/db.js"); 6 + return { db: createTestDb() }; 7 + }); 8 + 9 + vi.mock("@/config.js", () => ({ 10 + config: { 11 + databasePath: ":memory:", 12 + publicUrl: "http://localhost:5175", 13 + pdsUrl: "", 14 + jetstreamUrl: "wss://test/subscribe", 15 + cookieSecret: "test", 16 + nsidAllowlist: [], 17 + nsidBlocklist: [], 18 + }, 19 + })); 20 + 21 + import { GET } from "./logs.js"; 22 + import { db } from "@/db/index.js"; 23 + import { automations, deliveryLogs } from "@/db/schema.js"; 24 + 25 + const TEST_USER = { did: "did:plc:testuser", handle: "test.bsky.social" }; 26 + const TEST_AUTO = { 27 + uri: "at://did:plc:testuser/run.airglow.automation/rk1", 28 + did: "did:plc:testuser", 29 + rkey: "rk1", 30 + name: "Test Auto", 31 + description: null, 32 + lexicon: "app.bsky.feed.like", 33 + actions: [{ $type: "webhook" as const, callbackUrl: "https://example.com/hook", secret: "sec" }], 34 + fetches: [] as any[], 35 + conditions: [] as any[], 36 + active: true, 37 + indexedAt: new Date("2024-01-01"), 38 + }; 39 + 40 + function createTestApp() { 41 + const app = new Hono<{ Variables: { user: typeof TEST_USER } }>(); 42 + app.use("*", async (c, next) => { 43 + c.set("user", TEST_USER); 44 + return next(); 45 + }); 46 + app.get("/api/automations/:rkey/logs", ...GET); 47 + return app; 48 + } 49 + 50 + function insertLogs(count: number) { 51 + const values = Array.from({ length: count }, (_, i) => ({ 52 + automationUri: TEST_AUTO.uri, 53 + actionIndex: 0, 54 + eventTimeUs: 1700000000000000 + i, 55 + payload: null, 56 + statusCode: 200, 57 + error: null, 58 + attempt: 1, 59 + createdAt: new Date(Date.now() - i * 1000), 60 + })); 61 + return db.insert(deliveryLogs).values(values); 62 + } 63 + 64 + describe("GET /api/automations/:rkey/logs", () => { 65 + let app: ReturnType<typeof createTestApp>; 66 + 67 + beforeEach(async () => { 68 + app = createTestApp(); 69 + await db.delete(deliveryLogs); 70 + await db.delete(automations); 71 + await db.insert(automations).values(TEST_AUTO); 72 + }); 73 + 74 + it("returns logs with hasMore false when fewer than 50", async () => { 75 + await insertLogs(3); 76 + 77 + const res = await app.request("http://localhost/api/automations/rk1/logs"); 78 + expect(res.status).toBe(200); 79 + const body = await res.json(); 80 + expect(body.logs).toHaveLength(3); 81 + expect(body.hasMore).toBe(false); 82 + }); 83 + 84 + it("returns 50 logs with hasMore true when more exist", async () => { 85 + await insertLogs(51); 86 + 87 + const res = await app.request("http://localhost/api/automations/rk1/logs"); 88 + expect(res.status).toBe(200); 89 + const body = await res.json(); 90 + expect(body.logs).toHaveLength(50); 91 + expect(body.hasMore).toBe(true); 92 + }); 93 + 94 + it("respects before cursor", async () => { 95 + await insertLogs(60); 96 + 97 + // First page 98 + const res1 = await app.request("http://localhost/api/automations/rk1/logs"); 99 + const page1 = await res1.json(); 100 + expect(page1.logs).toHaveLength(50); 101 + expect(page1.hasMore).toBe(true); 102 + 103 + // Second page using last ID as cursor 104 + const lastId = page1.logs[page1.logs.length - 1].id; 105 + const res2 = await app.request(`http://localhost/api/automations/rk1/logs?before=${lastId}`); 106 + const page2 = await res2.json(); 107 + expect(page2.logs).toHaveLength(10); 108 + expect(page2.hasMore).toBe(false); 109 + 110 + // No overlap between pages 111 + const page1Ids = new Set(page1.logs.map((l: any) => l.id)); 112 + for (const log of page2.logs) { 113 + expect(page1Ids.has(log.id)).toBe(false); 114 + } 115 + }); 116 + 117 + it("returns 404 for non-existent automation", async () => { 118 + const res = await app.request("http://localhost/api/automations/nonexistent/logs"); 119 + expect(res.status).toBe(404); 120 + }); 121 + 122 + it("returns 404 for another user's automation", async () => { 123 + await db.delete(automations); 124 + await db.insert(automations).values({ ...TEST_AUTO, did: "did:plc:other" }); 125 + 126 + const res = await app.request("http://localhost/api/automations/rk1/logs"); 127 + expect(res.status).toBe(404); 128 + }); 129 + 130 + it("ignores invalid before value", async () => { 131 + await insertLogs(3); 132 + 133 + const res = await app.request("http://localhost/api/automations/rk1/logs?before=abc"); 134 + expect(res.status).toBe(200); 135 + const body = await res.json(); 136 + expect(body.logs).toHaveLength(3); 137 + }); 138 + });
+42
app/routes/api/automations/[rkey]/logs.ts
··· 1 + import { createRoute } from "honox/factory"; 2 + import { and, desc, eq, lt } from "drizzle-orm"; 3 + import { db } from "@/db/index.js"; 4 + import { automations, deliveryLogs } from "@/db/schema.js"; 5 + 6 + const PAGE_SIZE = 50; 7 + 8 + export const GET = createRoute(async (c) => { 9 + const user = c.get("user"); 10 + const rkey = c.req.param("rkey")!; 11 + const before = Number(c.req.query("before")) || undefined; 12 + 13 + const auto = await db.query.automations.findFirst({ 14 + where: and(eq(automations.did, user.did), eq(automations.rkey, rkey)), 15 + }); 16 + if (!auto) return c.json({ error: "Automation not found" }, 404); 17 + 18 + const conditions = [eq(deliveryLogs.automationUri, auto.uri)]; 19 + if (before) conditions.push(lt(deliveryLogs.id, before)); 20 + 21 + const rows = await db.query.deliveryLogs.findMany({ 22 + where: and(...conditions), 23 + orderBy: desc(deliveryLogs.id), 24 + limit: PAGE_SIZE + 1, 25 + }); 26 + 27 + const hasMore = rows.length > PAGE_SIZE; 28 + const logs = rows.slice(0, PAGE_SIZE); 29 + 30 + return c.json({ 31 + logs: logs.map((l) => ({ 32 + id: l.id, 33 + actionIndex: l.actionIndex, 34 + eventTimeUs: l.eventTimeUs, 35 + statusCode: l.statusCode, 36 + error: l.error, 37 + attempt: l.attempt, 38 + createdAt: l.createdAt.getTime(), 39 + })), 40 + hasMore, 41 + }); 42 + });
+6 -3
app/routes/dashboard/automations/[rkey].tsx
··· 43 43 ); 44 44 } 45 45 46 - const logs = await db.query.deliveryLogs.findMany({ 46 + const rawLogs = await db.query.deliveryLogs.findMany({ 47 47 where: eq(deliveryLogs.automationUri, auto.uri), 48 - orderBy: desc(deliveryLogs.createdAt), 49 - limit: 50, 48 + orderBy: desc(deliveryLogs.id), 49 + limit: 51, 50 50 }); 51 + const hasMore = rawLogs.length > 50; 52 + const logs = rawLogs.slice(0, 50); 51 53 52 54 return c.render( 53 55 <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> ··· 186 188 <DeliveryLog 187 189 rkey={auto.rkey} 188 190 active={auto.active} 191 + hasMore={hasMore} 189 192 initialLogs={logs.map((l) => ({ 190 193 id: l.id, 191 194 actionIndex: l.actionIndex,