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.

feat: logs page

Hugo 08059e90 da4623be

+744 -47
+27
app/components/LogStatusIcon/index.tsx
··· 1 + import { ChevronDown, ChevronRight, CircleAlert, CircleSmall } from "../../icons.js"; 2 + import * as s from "./styles.css.ts"; 3 + 4 + type Props = { 5 + error: string | null; 6 + hasDetails: boolean; 7 + expanded: boolean; 8 + }; 9 + 10 + export function LogStatusIcon({ error, hasDetails, expanded }: Props) { 11 + const cls = error ? s.error : hasDetails ? s.chevron : s.dot; 12 + return ( 13 + <span class={cls}> 14 + {error ? ( 15 + <CircleAlert size={14} /> 16 + ) : hasDetails ? ( 17 + expanded ? ( 18 + <ChevronDown size={14} /> 19 + ) : ( 20 + <ChevronRight size={14} /> 21 + ) 22 + ) : ( 23 + <CircleSmall size={14} /> 24 + )} 25 + </span> 26 + ); 27 + }
+18
app/components/LogStatusIcon/styles.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../../styles/theme.css.ts"; 3 + 4 + export const chevron = style({ 5 + color: vars.color.textMuted, 6 + display: "inline-flex", 7 + }); 8 + 9 + export const error = style({ 10 + color: vars.color.error, 11 + display: "inline-flex", 12 + }); 13 + 14 + export const dot = style({ 15 + color: vars.color.textMuted, 16 + display: "inline-flex", 17 + opacity: 0.5, 18 + });
+2
app/icons.ts
··· 59 59 import SettingsData from "lucide/icons/settings"; 60 60 import FileTextData from "lucide/icons/file-text"; 61 61 import SearchData from "lucide/icons/search"; 62 + import CircleSmallData from "lucide/icons/circle-small"; 62 63 63 64 export const Activity = icon(ActivityData); 64 65 export const ArrowLeft = icon(ArrowLeftData); ··· 92 93 export const Settings = icon(SettingsData); 93 94 export const FileText = icon(FileTextData); 94 95 export const Search = icon(SearchData); 96 + export const CircleSmall = icon(CircleSmallData);
-10
app/islands/DeliveryLog.css.ts
··· 128 128 gap: space[2], 129 129 }); 130 130 131 - export const chevronIcon = style({ 132 - color: vars.color.textMuted, 133 - display: "inline-flex", 134 - }); 135 - 136 - export const errorIcon = style({ 137 - color: vars.color.error, 138 - display: "inline-flex", 139 - }); 140 - 141 131 export const clickableRow = style({ 142 132 cursor: "pointer", 143 133 ":hover": {
+7 -20
app/islands/DeliveryLog.tsx
··· 1 1 import { useState, useCallback, useRef } from "hono/jsx"; 2 - import { 3 - Power, 4 - FlaskConical, 5 - Trash2, 6 - RefreshCw, 7 - ChevronRight, 8 - ChevronDown, 9 - CircleAlert, 10 - } from "../icons.js"; 2 + import { Power, FlaskConical, Trash2, RefreshCw } from "../icons.js"; 3 + import { LogStatusIcon } from "../components/LogStatusIcon/index.js"; 11 4 import { SCOPE_INSUFFICIENT, redirectToScopeUpgrade } from "../../lib/auth/scope-errors.js"; 12 5 import * as s from "./DeliveryLog.css.ts"; 13 6 import { variant as badgeVariant } from "../components/Badge/styles.css.ts"; ··· 246 239 > 247 240 <td class={s.td}> 248 241 <span class={s.timeCell}> 249 - {hasDetails && ( 250 - <span class={log.error ? s.errorIcon : s.chevronIcon}> 251 - {log.error ? ( 252 - <CircleAlert size={14} /> 253 - ) : expanded ? ( 254 - <ChevronDown size={14} /> 255 - ) : ( 256 - <ChevronRight size={14} /> 257 - )} 258 - </span> 259 - )} 242 + <LogStatusIcon 243 + error={log.error} 244 + hasDetails={hasDetails} 245 + expanded={expanded} 246 + /> 260 247 {new Date(log.createdAt).toLocaleString()} 261 248 </span> 262 249 </td>
+159
app/islands/LogsBrowser.css.ts
··· 1 + import { style } from "@vanilla-extract/css"; 2 + import { vars } from "../styles/theme.css.ts"; 3 + import { space } from "../styles/tokens/spacing.ts"; 4 + import { fontSize, fontWeight } from "../styles/tokens/typography.ts"; 5 + import { radii } from "../styles/tokens/radii.ts"; 6 + 7 + export const wrapper = style({ 8 + display: "flex", 9 + flexDirection: "column", 10 + gap: space[4], 11 + }); 12 + 13 + export const filterBar = style({ 14 + display: "grid", 15 + gridTemplateColumns: "2fr 1fr 1fr", 16 + gap: space[3], 17 + alignItems: "end", 18 + "@media": { 19 + "(max-width: 640px)": { 20 + gridTemplateColumns: "1fr", 21 + }, 22 + }, 23 + }); 24 + 25 + export const field = style({ 26 + display: "flex", 27 + flexDirection: "column", 28 + gap: space[1], 29 + }); 30 + 31 + export const fieldLabel = style({ 32 + fontSize: fontSize.xs, 33 + fontWeight: fontWeight.medium, 34 + color: vars.color.textMuted, 35 + }); 36 + 37 + export const tableWrapper = style({ 38 + overflowX: "auto", 39 + borderRadius: radii.md, 40 + border: `1px solid ${vars.color.border}`, 41 + }); 42 + 43 + export const table = style({ 44 + width: "100%", 45 + fontSize: fontSize.sm, 46 + textAlign: "start", 47 + }); 48 + 49 + export const th = style({ 50 + paddingBlock: space[3], 51 + paddingInline: space[4], 52 + fontWeight: fontWeight.semibold, 53 + color: vars.color.heading, 54 + textAlign: "start", 55 + whiteSpace: "nowrap", 56 + backgroundColor: vars.color.surfaceHover, 57 + }); 58 + 59 + export const td = style({ 60 + paddingBlock: space[3], 61 + paddingInline: space[4], 62 + color: vars.color.text, 63 + borderBlockStart: `1px solid ${vars.color.borderSubtle}`, 64 + }); 65 + 66 + export const timeCell = style({ 67 + display: "inline-flex", 68 + alignItems: "center", 69 + gap: space[2], 70 + }); 71 + 72 + export const clickableRow = style({ 73 + cursor: "pointer", 74 + ":hover": { 75 + backgroundColor: vars.color.surfaceHover, 76 + }, 77 + }); 78 + 79 + export const expandedRow = style({ 80 + backgroundColor: vars.color.surfaceHover, 81 + selectors: { 82 + "&:hover": { 83 + backgroundColor: vars.color.surfaceHover, 84 + }, 85 + }, 86 + }); 87 + 88 + export const dryRunRow = style({ 89 + fontStyle: "italic", 90 + opacity: 0.75, 91 + }); 92 + 93 + export const detailCell = style({ 94 + paddingBlockStart: 0, 95 + paddingBlockEnd: space[3], 96 + paddingInlineStart: space[8], 97 + paddingInlineEnd: space[4], 98 + fontSize: fontSize.sm, 99 + lineHeight: 1.5, 100 + wordBreak: "break-word", 101 + backgroundColor: vars.color.surfaceHover, 102 + borderBlockStart: "none", 103 + }); 104 + 105 + export const detailError = style({ 106 + color: vars.color.error, 107 + }); 108 + 109 + export const automationLink = style({ 110 + color: vars.color.text, 111 + ":hover": { 112 + color: vars.color.link, 113 + }, 114 + }); 115 + 116 + export const emptyState = style({ 117 + color: vars.color.textMuted, 118 + fontSize: fontSize.sm, 119 + paddingBlock: space[6], 120 + textAlign: "center", 121 + }); 122 + 123 + export const loadMoreWrapper = style({ 124 + display: "flex", 125 + justifyContent: "center", 126 + paddingBlockStart: space[2], 127 + }); 128 + 129 + export const loadMoreBtn = style({ 130 + display: "inline-flex", 131 + alignItems: "center", 132 + justifyContent: "center", 133 + gap: space[1], 134 + paddingBlock: space[1], 135 + paddingInline: space[3], 136 + fontSize: fontSize.sm, 137 + fontWeight: fontWeight.medium, 138 + borderRadius: radii.md, 139 + cursor: "pointer", 140 + border: `1px solid ${vars.color.border}`, 141 + minBlockSize: "32px", 142 + lineHeight: 1, 143 + color: vars.color.textSecondary, 144 + backgroundColor: "transparent", 145 + ":hover": { 146 + backgroundColor: vars.color.surfaceHover, 147 + color: vars.color.text, 148 + }, 149 + selectors: { 150 + "&:disabled": { 151 + opacity: 0.5, 152 + cursor: "not-allowed", 153 + }, 154 + }, 155 + }); 156 + 157 + export const busy = style({ 158 + opacity: 0.6, 159 + });
+291
app/islands/LogsBrowser.tsx
··· 1 + import { useCallback, useEffect, useMemo, useRef, useState } from "hono/jsx"; 2 + import { RefreshCw } from "../icons.js"; 3 + import { Input } from "../components/Input/index.js"; 4 + import { Select } from "../components/Select/index.js"; 5 + import { LogStatusIcon } from "../components/LogStatusIcon/index.js"; 6 + import type { SerializedLogRow } from "@/actions/logs-query.js"; 7 + import * as s from "./LogsBrowser.css.ts"; 8 + 9 + type AutomationOption = { 10 + rkey: string; 11 + name: string; 12 + actionLabels: string[]; 13 + }; 14 + 15 + type Filters = { 16 + q: string; 17 + automation: string; 18 + status: string; 19 + }; 20 + 21 + type Props = { 22 + automations: AutomationOption[]; 23 + initialLogs: SerializedLogRow[]; 24 + initialHasMore: boolean; 25 + initialFilters: Filters; 26 + }; 27 + 28 + const DEBOUNCE_MS = 300; 29 + 30 + const buildSearch = (filters: Filters) => { 31 + const params = new URLSearchParams(); 32 + const trimmed = filters.q.trim(); 33 + if (trimmed) params.set("q", trimmed); 34 + if (filters.automation) params.set("automation", filters.automation); 35 + if (filters.status) params.set("status", filters.status); 36 + return params.toString(); 37 + }; 38 + 39 + const readFilters = (): Filters => { 40 + const params = new URLSearchParams(window.location.search); 41 + return { 42 + q: params.get("q") ?? "", 43 + automation: params.get("automation") ?? "", 44 + status: params.get("status") ?? "", 45 + }; 46 + }; 47 + 48 + export default function LogsBrowser({ 49 + automations, 50 + initialLogs, 51 + initialHasMore, 52 + initialFilters, 53 + }: Props) { 54 + const [logs, setLogs] = useState(initialLogs); 55 + const [hasMore, setHasMore] = useState(initialHasMore); 56 + const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set()); 57 + const [filters, setFilters] = useState<Filters>(initialFilters); 58 + const [busy, setBusy] = useState(false); 59 + const [loadingMore, setLoadingMore] = useState(false); 60 + const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); 61 + const abortRef = useRef<AbortController | null>(null); 62 + const lastFetchedRef = useRef<string>(buildSearch(initialFilters)); 63 + const filtersRef = useRef<Filters>(filters); 64 + filtersRef.current = filters; 65 + 66 + const actionLabelsByRkey = useMemo( 67 + () => new Map(automations.map((a) => [a.rkey, a.actionLabels])), 68 + [automations], 69 + ); 70 + 71 + const fetchLogs = useCallback( 72 + async (next: Filters, mode: "replace" | "append", before?: number) => { 73 + abortRef.current?.abort(); 74 + const ctrl = new AbortController(); 75 + abortRef.current = ctrl; 76 + if (mode === "replace") setBusy(true); 77 + else setLoadingMore(true); 78 + try { 79 + const params = new URLSearchParams(buildSearch(next)); 80 + if (before) params.set("before", String(before)); 81 + const res = await fetch(`/api/delivery-logs?${params.toString()}`, { signal: ctrl.signal }); 82 + if (!res.ok) return; 83 + const data = (await res.json()) as { logs: SerializedLogRow[]; hasMore: boolean }; 84 + if (mode === "replace") { 85 + setLogs(data.logs); 86 + setExpandedIds(new Set()); 87 + } else { 88 + setLogs((prev) => [...prev, ...data.logs]); 89 + } 90 + setHasMore(data.hasMore); 91 + } catch (err) { 92 + if ((err as Error).name !== "AbortError") console.error(err); 93 + } finally { 94 + if (abortRef.current === ctrl) abortRef.current = null; 95 + setBusy(false); 96 + setLoadingMore(false); 97 + } 98 + }, 99 + [], 100 + ); 101 + 102 + // On filter changes: debounce, sync URL, refetch. The lastFetchedRef equality 103 + // check makes the initial render a no-op (it was seeded from initialFilters). 104 + useEffect(() => { 105 + if (debounceRef.current) clearTimeout(debounceRef.current); 106 + debounceRef.current = setTimeout(() => { 107 + const search = buildSearch(filters); 108 + if (search === lastFetchedRef.current) return; 109 + const url = search ? `?${search}` : window.location.pathname; 110 + history.replaceState(null, "", url); 111 + lastFetchedRef.current = search; 112 + void fetchLogs(filters, "replace"); 113 + }, DEBOUNCE_MS); 114 + return () => { 115 + if (debounceRef.current) clearTimeout(debounceRef.current); 116 + }; 117 + }, [filters, fetchLogs]); 118 + 119 + useEffect(() => { 120 + const onPopState = () => { 121 + const next = readFilters(); 122 + const current = filtersRef.current ?? next; 123 + if (buildSearch(next) === buildSearch(current)) return; 124 + lastFetchedRef.current = buildSearch(next); 125 + setFilters(next); 126 + void fetchLogs(next, "replace"); 127 + }; 128 + window.addEventListener("popstate", onPopState); 129 + return () => window.removeEventListener("popstate", onPopState); 130 + }, [fetchLogs]); 131 + 132 + const toggleExpand = (id: number) => 133 + setExpandedIds((prev) => { 134 + const next = new Set(prev); 135 + if (next.has(id)) next.delete(id); 136 + else next.add(id); 137 + return next; 138 + }); 139 + 140 + const actionLabelFor = (log: SerializedLogRow) => { 141 + const labels = actionLabelsByRkey.get(log.automationRkey); 142 + return labels?.[log.actionIndex] ?? `Action ${log.actionIndex + 1}`; 143 + }; 144 + 145 + const updateFilter = <K extends keyof Filters>(key: K, value: Filters[K]) => 146 + setFilters((prev) => ({ ...prev, [key]: value })); 147 + 148 + const loadMore = () => { 149 + const last = logs[logs.length - 1]; 150 + if (!last) return; 151 + void fetchLogs(filters, "append", last.id); 152 + }; 153 + 154 + return ( 155 + <div class={s.wrapper}> 156 + <div class={s.filterBar}> 157 + <div class={s.field}> 158 + <label class={s.fieldLabel} for="logs-q"> 159 + Search 160 + </label> 161 + <Input 162 + id="logs-q" 163 + type="search" 164 + placeholder="Search message or error..." 165 + value={filters.q} 166 + onInput={(e: Event) => updateFilter("q", (e.currentTarget as HTMLInputElement).value)} 167 + /> 168 + </div> 169 + <div class={s.field}> 170 + <label class={s.fieldLabel} for="logs-automation"> 171 + Automation 172 + </label> 173 + <Select 174 + id="logs-automation" 175 + value={filters.automation} 176 + onChange={(e: Event) => 177 + updateFilter("automation", (e.currentTarget as HTMLSelectElement).value) 178 + } 179 + > 180 + <option value="">All automations</option> 181 + {automations.map((a) => ( 182 + <option key={a.rkey} value={a.rkey}> 183 + {a.name} 184 + </option> 185 + ))} 186 + </Select> 187 + </div> 188 + <div class={s.field}> 189 + <label class={s.fieldLabel} for="logs-status"> 190 + Status 191 + </label> 192 + <Select 193 + id="logs-status" 194 + value={filters.status} 195 + onChange={(e: Event) => 196 + updateFilter("status", (e.currentTarget as HTMLSelectElement).value) 197 + } 198 + > 199 + <option value="">All</option> 200 + <option value="success">Success</option> 201 + <option value="error">Errors</option> 202 + <option value="dryRun">Dry run</option> 203 + </Select> 204 + </div> 205 + </div> 206 + 207 + {logs.length === 0 ? ( 208 + <p class={s.emptyState}>{busy ? "Loading..." : "No deliveries match these filters."}</p> 209 + ) : ( 210 + <div class={`${s.tableWrapper}${busy ? ` ${s.busy}` : ""}`}> 211 + <table class={s.table}> 212 + <thead> 213 + <tr> 214 + <th class={s.th}>Time</th> 215 + <th class={s.th}>Automation</th> 216 + <th class={s.th}>Action</th> 217 + <th class={s.th}>Status</th> 218 + <th class={s.th}>Attempt</th> 219 + </tr> 220 + </thead> 221 + <tbody> 222 + {logs.map((log) => { 223 + const expanded = expandedIds.has(log.id); 224 + const hasDetails = !!(log.message || log.error); 225 + return ( 226 + <> 227 + <tr 228 + key={log.id} 229 + class={ 230 + `${log.dryRun ? s.dryRunRow : ""} ${hasDetails ? s.clickableRow : ""} ${expanded ? s.expandedRow : ""}`.trim() || 231 + undefined 232 + } 233 + onClick={hasDetails ? () => toggleExpand(log.id) : undefined} 234 + > 235 + <td class={s.td}> 236 + <span class={s.timeCell}> 237 + <LogStatusIcon 238 + error={log.error} 239 + hasDetails={hasDetails} 240 + expanded={expanded} 241 + /> 242 + {new Date(log.createdAt).toLocaleString()} 243 + </span> 244 + </td> 245 + <td class={s.td}> 246 + <a 247 + class={s.automationLink} 248 + href={`/dashboard/automations/${log.automationRkey}`} 249 + onClick={(e: MouseEvent) => e.stopPropagation()} 250 + > 251 + {log.automationName} 252 + </a> 253 + </td> 254 + <td class={s.td}>{actionLabelFor(log)}</td> 255 + <td class={s.td}>{log.dryRun ? "dry run" : (log.statusCode ?? "\u2014")}</td> 256 + <td class={s.td}>{log.attempt}</td> 257 + </tr> 258 + {expanded && ( 259 + <tr key={`${log.id}-detail`}> 260 + <td class={s.detailCell} colSpan={5}> 261 + {log.message && ( 262 + <p> 263 + <strong>Message:</strong> {log.message} 264 + </p> 265 + )} 266 + {log.error && ( 267 + <p class={s.detailError}> 268 + <strong>Error:</strong> {log.error} 269 + </p> 270 + )} 271 + </td> 272 + </tr> 273 + )} 274 + </> 275 + ); 276 + })} 277 + </tbody> 278 + </table> 279 + </div> 280 + )} 281 + 282 + {hasMore && ( 283 + <div class={s.loadMoreWrapper}> 284 + <button type="button" class={s.loadMoreBtn} onClick={loadMore} disabled={loadingMore}> 285 + <RefreshCw size={14} /> {loadingMore ? "Loading..." : "Load more"} 286 + </button> 287 + </div> 288 + )} 289 + </div> 290 + ); 291 + }
+6 -14
app/routes/api/automations/[rkey]/logs.ts
··· 1 1 import { createRoute } from "honox/factory"; 2 - import { and, desc, eq, lt } from "drizzle-orm"; 2 + import { and, eq } from "drizzle-orm"; 3 3 import { db } from "@/db/index.js"; 4 - import { automations, deliveryLogs } from "@/db/schema.js"; 5 - 6 - const PAGE_SIZE = 50; 4 + import { automations } from "@/db/schema.js"; 5 + import { queryDeliveryLogs } from "@/actions/logs-query.js"; 7 6 8 7 export const GET = createRoute(async (c) => { 9 8 const user = c.get("user"); ··· 15 14 }); 16 15 if (!auto) return c.json({ error: "Automation not found" }, 404); 17 16 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, 17 + const { logs, hasMore } = await queryDeliveryLogs({ 18 + automationUris: [auto.uri], 19 + before, 25 20 }); 26 - 27 - const hasMore = rows.length > PAGE_SIZE; 28 - const logs = rows.slice(0, PAGE_SIZE); 29 21 30 22 return c.json({ 31 23 logs: logs.map((l) => ({
+36
app/routes/api/delivery-logs.ts
··· 1 + import { createRoute } from "honox/factory"; 2 + import { eq } from "drizzle-orm"; 3 + import { requireApiAuth } from "@/auth/middleware.js"; 4 + import { db } from "@/db/index.js"; 5 + import { automations } from "@/db/schema.js"; 6 + import { parseLogStatus, queryDeliveryLogs, serializeLogRow } from "@/actions/logs-query.js"; 7 + 8 + export const GET = createRoute(requireApiAuth, async (c) => { 9 + const user = c.get("user"); 10 + const before = Number(c.req.query("before")) || undefined; 11 + const rkey = c.req.query("automation") || undefined; 12 + const status = parseLogStatus(c.req.query("status")); 13 + const q = c.req.query("q")?.trim() || undefined; 14 + 15 + const userAutos = await db.query.automations.findMany({ 16 + where: eq(automations.did, user.did), 17 + columns: { uri: true, rkey: true, name: true }, 18 + }); 19 + 20 + const uriByRkey = new Map(userAutos.map((a) => [a.rkey, a.uri])); 21 + const nameByUri = new Map(userAutos.map((a) => [a.uri, a.name])); 22 + const rkeyByUri = new Map(userAutos.map((a) => [a.uri, a.rkey])); 23 + 24 + const { logs, hasMore } = await queryDeliveryLogs({ 25 + automationUris: userAutos.map((a) => a.uri), 26 + automationUri: rkey ? uriByRkey.get(rkey) : undefined, 27 + status, 28 + q, 29 + before, 30 + }); 31 + 32 + return c.json({ 33 + logs: logs.map((l) => serializeLogRow(l, nameByUri, rkeyByUri)), 34 + hasMore, 35 + }); 36 + });
+6 -3
app/routes/dashboard/index.tsx
··· 1 1 import { createRoute } from "honox/factory"; 2 2 import { eq } from "drizzle-orm"; 3 - import { Plus, Eye, Settings } from "../../icons.js"; 3 + import { Plus, Eye, Settings, FileText } from "../../icons.js"; 4 4 import { db } from "@/db/index.js"; 5 5 import { automations } from "@/db/schema.js"; 6 6 import { AppShell } from "../../components/Layout/AppShell/index.js"; ··· 15 15 import { NsidCode } from "../../components/NsidCode/index.js"; 16 16 import { actionTypeLabels } from "@/automations/labels.js"; 17 17 import ThemeToggle from "../../islands/ThemeToggle.js"; 18 - import { centerTextSm, pushEnd } from "../../styles/utilities.css.js"; 18 + import { centerTextSm, inlineCluster, pushEnd } from "../../styles/utilities.css.js"; 19 19 20 20 export default createRoute(async (c) => { 21 21 const user = c.get("user"); ··· 34 34 <Button href="/dashboard/automations/new" size="sm"> 35 35 <Plus size={16} /> New Automation 36 36 </Button> 37 - <span class={pushEnd}> 37 + <span class={`${pushEnd} ${inlineCluster}`}> 38 + <Button href="/dashboard/logs" variant="ghost" size="sm"> 39 + <FileText size={14} /> Logs 40 + </Button> 38 41 <Button href="/dashboard/secrets" variant="ghost" size="sm"> 39 42 <Settings size={14} /> Secrets 40 43 </Button>
+77
app/routes/dashboard/logs.tsx
··· 1 + import { createRoute } from "honox/factory"; 2 + import { eq } from "drizzle-orm"; 3 + import { db } from "@/db/index.js"; 4 + import { automations } from "@/db/schema.js"; 5 + import { actionTypeLabels } from "@/automations/labels.js"; 6 + import { parseLogStatus, queryDeliveryLogs, serializeLogRow } from "@/actions/logs-query.js"; 7 + import { AppShell } from "../../components/Layout/AppShell/index.js"; 8 + import { Header } from "../../components/Layout/Header/index.js"; 9 + import { Container } from "../../components/Layout/Container/index.js"; 10 + import { PageHeader } from "../../components/Layout/PageHeader/index.js"; 11 + import { Card } from "../../components/Card/index.js"; 12 + import { centerTextSm } from "../../styles/utilities.css.js"; 13 + import ThemeToggle from "../../islands/ThemeToggle.js"; 14 + import LogsBrowser from "../../islands/LogsBrowser.js"; 15 + 16 + export default createRoute(async (c) => { 17 + const user = c.get("user"); 18 + 19 + const autos = await db.query.automations.findMany({ 20 + where: eq(automations.did, user.did), 21 + columns: { uri: true, rkey: true, name: true, actions: true }, 22 + }); 23 + 24 + if (autos.length === 0) { 25 + return c.render( 26 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 27 + <Container> 28 + <PageHeader title="Delivery Logs" description="All deliveries across your automations" /> 29 + <Card variant="flat"> 30 + <div class={centerTextSm}> 31 + <p>No automations yet.</p> 32 + </div> 33 + </Card> 34 + </Container> 35 + </AppShell>, 36 + { title: "Delivery Logs | Airglow" }, 37 + ); 38 + } 39 + 40 + const q = c.req.query("q")?.trim() || ""; 41 + const automationRkey = c.req.query("automation") || ""; 42 + const status = parseLogStatus(c.req.query("status")); 43 + 44 + const uriByRkey = new Map(autos.map((a) => [a.rkey, a.uri])); 45 + const nameByUri = new Map(autos.map((a) => [a.uri, a.name])); 46 + const rkeyByUri = new Map(autos.map((a) => [a.uri, a.rkey])); 47 + 48 + const { logs, hasMore } = await queryDeliveryLogs({ 49 + automationUris: autos.map((a) => a.uri), 50 + automationUri: automationRkey ? uriByRkey.get(automationRkey) : undefined, 51 + status, 52 + q: q || undefined, 53 + }); 54 + 55 + const automationOptions = autos.map((a) => ({ 56 + rkey: a.rkey, 57 + name: a.name, 58 + actionLabels: a.actions.map((act) => actionTypeLabels[act.$type] ?? act.$type), 59 + })); 60 + 61 + return c.render( 62 + <AppShell header={<Header user={user} actions={<ThemeToggle />} />}> 63 + <Container> 64 + <PageHeader title="Delivery Logs" description="All deliveries across your automations" /> 65 + <Card variant="flat"> 66 + <LogsBrowser 67 + automations={automationOptions} 68 + initialHasMore={hasMore} 69 + initialFilters={{ q, automation: automationRkey, status: status ?? "" }} 70 + initialLogs={logs.map((l) => serializeLogRow(l, nameByUri, rkeyByUri))} 71 + /> 72 + </Card> 73 + </Container> 74 + </AppShell>, 75 + { title: "Delivery Logs | Airglow" }, 76 + ); 77 + });
+115
lib/actions/logs-query.ts
··· 1 + import { 2 + and, 3 + desc, 4 + eq, 5 + gte, 6 + inArray, 7 + isNotNull, 8 + isNull, 9 + like, 10 + lt, 11 + or, 12 + type SQL, 13 + } from "drizzle-orm"; 14 + import { db } from "../db/index.js"; 15 + import { deliveryLogs } from "../db/schema.js"; 16 + 17 + export const LOG_PAGE_SIZE = 50; 18 + 19 + export type LogStatusFilter = "success" | "error" | "dryRun"; 20 + 21 + export type LogFilters = { 22 + automationUris: string[]; 23 + /** When set, narrows to a single automation by uri. Must be within automationUris. */ 24 + automationUri?: string; 25 + status?: LogStatusFilter; 26 + q?: string; 27 + before?: number; 28 + }; 29 + 30 + export type LogRow = typeof deliveryLogs.$inferSelect; 31 + 32 + export type SerializedLogRow = { 33 + id: number; 34 + automationUri: string; 35 + automationName: string; 36 + automationRkey: string; 37 + actionIndex: number; 38 + eventTimeUs: number; 39 + statusCode: number | null; 40 + message: string | null; 41 + error: string | null; 42 + dryRun: boolean; 43 + attempt: number; 44 + createdAt: number; 45 + }; 46 + 47 + export function serializeLogRow( 48 + l: LogRow, 49 + nameByUri: Map<string, string>, 50 + rkeyByUri: Map<string, string>, 51 + ): SerializedLogRow { 52 + return { 53 + id: l.id, 54 + automationUri: l.automationUri, 55 + automationName: nameByUri.get(l.automationUri) ?? "", 56 + automationRkey: rkeyByUri.get(l.automationUri) ?? "", 57 + actionIndex: l.actionIndex, 58 + eventTimeUs: l.eventTimeUs, 59 + statusCode: l.statusCode, 60 + message: l.message, 61 + error: l.error, 62 + dryRun: l.dryRun, 63 + attempt: l.attempt, 64 + createdAt: l.createdAt.getTime(), 65 + }; 66 + } 67 + 68 + export async function queryDeliveryLogs( 69 + filters: LogFilters, 70 + ): Promise<{ logs: LogRow[]; hasMore: boolean }> { 71 + if (filters.automationUris.length === 0) return { logs: [], hasMore: false }; 72 + 73 + const conditions: SQL[] = []; 74 + if (filters.automationUri && filters.automationUris.includes(filters.automationUri)) { 75 + conditions.push(eq(deliveryLogs.automationUri, filters.automationUri)); 76 + } else { 77 + conditions.push(inArray(deliveryLogs.automationUri, filters.automationUris)); 78 + } 79 + 80 + if (filters.before) conditions.push(lt(deliveryLogs.id, filters.before)); 81 + 82 + if (filters.status === "dryRun") { 83 + conditions.push(eq(deliveryLogs.dryRun, true)); 84 + } else if (filters.status === "error") { 85 + conditions.push(or(isNotNull(deliveryLogs.error), gte(deliveryLogs.statusCode, 400))!); 86 + } else if (filters.status === "success") { 87 + conditions.push(eq(deliveryLogs.dryRun, false)); 88 + conditions.push(isNull(deliveryLogs.error)); 89 + conditions.push(lt(deliveryLogs.statusCode, 400)); 90 + } 91 + 92 + if (filters.q) { 93 + const pattern = `%${filters.q}%`; 94 + conditions.push(or(like(deliveryLogs.message, pattern), like(deliveryLogs.error, pattern))!); 95 + } 96 + 97 + const rows = await db.query.deliveryLogs.findMany({ 98 + where: and(...conditions), 99 + orderBy: desc(deliveryLogs.id), 100 + limit: LOG_PAGE_SIZE + 1, 101 + }); 102 + 103 + return { 104 + logs: rows.slice(0, LOG_PAGE_SIZE), 105 + hasMore: rows.length > LOG_PAGE_SIZE, 106 + }; 107 + } 108 + 109 + function isLogStatusFilter(v: string | undefined): v is LogStatusFilter { 110 + return v === "success" || v === "error" || v === "dryRun"; 111 + } 112 + 113 + export function parseLogStatus(v: string | undefined): LogStatusFilter | undefined { 114 + return isLogStatusFilter(v) ? v : undefined; 115 + }