Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions dashboard/src/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -191,13 +191,19 @@ describe("dashboard routing and API query helpers", () => {
expect(screen.getByText("#42")).toBeInTheDocument();
});

it("shows sanitized hook configuration and recent delivery identity", () => {
render(<WebhookHookDetailPage data={{ hook: { id: "42", target: "gisce", target_type: "organization", name: "web", active: true, events: ["issue_comment"], content_type: "json", ssl_verify: true, delivery_url: "https://gab.gisce.net/api/webhooks/github", github_created_at: "2026-10-02T11:07:17Z", github_updated_at: "2026-10-02T11:07:17Z", last_ping_at: "2026-10-02T11:07:20Z", last_event_at: "2026-10-02T11:08:00Z", last_delivery_id: "delivery-1", last_event_name: "issue_comment", last_action: "created", last_repository: "gisce/github-agent-bridge", last_result: "observed", status: "receiving", admin_url: "https://github.com/organizations/gisce/settings/hooks/42" }, stats: { deliveries: 3, duplicates: 1, unsupported: 0 }, recent_deliveries: [{ delivery_id: "delivery-1", created_at: "2026-10-02T11:08:00Z", hook_id: "42", hook: { id: "42", target: "gisce", target_type: "organization" }, event_name: "issue_comment", action: "created", repository: "gisce/github-agent-bridge", status: "observed" }] }} loading={false} error={null} onBack={vi.fn()} onRefresh={vi.fn()} onViewHook={vi.fn()} />);
it("shows sanitized hook configuration and can request a fresh ping", async () => {
const onPing = vi.fn().mockResolvedValue("GitHub accepted the ping request.");
const user = userEvent.setup();
render(<WebhookHookDetailPage data={{ hook: { id: "42", target: "gisce", target_type: "organization", name: "web", active: true, events: ["issue_comment"], content_type: "json", ssl_verify: true, delivery_url: "https://gab.gisce.net/api/webhooks/github", ping_url: "https://api.github.com/orgs/gisce/hooks/42/pings", github_created_at: "2026-10-02T11:07:17Z", github_updated_at: "2026-10-02T11:07:17Z", last_ping_at: "2026-10-02T11:07:20Z", last_event_at: "2026-10-02T11:08:00Z", last_delivery_id: "delivery-1", last_event_name: "issue_comment", last_action: "created", last_repository: "gisce/github-agent-bridge", last_result: "observed", status: "receiving", admin_url: "https://github.com/organizations/gisce/settings/hooks/42" }, stats: { deliveries: 3, duplicates: 1, unsupported: 0 }, recent_actions: [{ id: 1, action: "ping", actor: "operator", status: "succeeded", created_at: "2026-10-02T11:07:19Z" }], recent_deliveries: [{ delivery_id: "delivery-1", created_at: "2026-10-02T11:08:00Z", hook_id: "42", hook: { id: "42", target: "gisce", target_type: "organization" }, event_name: "issue_comment", action: "created", repository: "gisce/github-agent-bridge", status: "observed" }] }} loading={false} error={null} onBack={vi.fn()} onRefresh={vi.fn()} onViewHook={vi.fn()} onPing={onPing} />);

expect(screen.getByRole("link", { name: /Open in GitHub/i })).toHaveAttribute("href", "https://github.com/organizations/gisce/settings/hooks/42");
expect(screen.getByText("https://gab.gisce.net/api/webhooks/github")).toBeInTheDocument();
expect(screen.getAllByText("issue_comment · created").length).toBeGreaterThan(0);
expect(screen.getAllByText("delivery-1").length).toBeGreaterThan(0);
expect(screen.getByText("by @operator")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Send ping" }));
expect(onPing).toHaveBeenCalledWith("42");
expect(await screen.findByText("GitHub accepted the ping request.")).toBeInTheDocument();
});

it("shows the full webhook payload and linked job status", async () => {
Expand Down
39 changes: 37 additions & 2 deletions dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,17 @@ type WebhookHookDetailResponse = {
hook: WebhookHook;
stats: { deliveries: number; duplicates: number; unsupported: number };
recent_deliveries: WebhookDelivery[];
recent_actions?: WebhookHookAction[];
};

type WebhookHookAction = {
id: number;
action: "ping";
actor: string;
status: "requested" | "succeeded" | "failed";
detail?: string | null;
created_at: string;
completed_at?: string | null;
};

type WebhookDeliveryDetailResponse = {
Expand Down Expand Up @@ -1059,6 +1070,12 @@ function App() {
queryFn: () => api<WebhookHookDetailResponse>(`/api/webhooks/github/hooks/${encodeURIComponent(selectedWebhookHookId ?? "")}`),
enabled: webhookEnabled && selectedWebhookHookId !== null,
});
const pingWebhookHook = React.useCallback(async (hookId: string) => {
const result = await api<{ detail: string }>(`/api/webhooks/github/hooks/${encodeURIComponent(hookId)}/ping`, { method: "POST" });
void webhookHookDetail.refetch();
window.setTimeout(() => void webhookHookDetail.refetch(), 1500);
return result.detail;
}, [webhookHookDetail]);
const webhookDeliveryDetail = useQuery({
queryKey: ["webhook-delivery", selectedWebhookDeliveryId],
queryFn: () => api<WebhookDeliveryDetailResponse>(`/api/webhooks/github/deliveries/${encodeURIComponent(selectedWebhookDeliveryId ?? "")}`),
Expand Down Expand Up @@ -1409,6 +1426,7 @@ function App() {
onRefresh={() => webhookHookDetail.refetch()}
onViewHook={(hookId) => navigateDashboard(`/webhooks/hooks/${encodeURIComponent(hookId)}`)}
onViewDelivery={(deliveryId) => navigateDashboard(`/webhooks/deliveries/${encodeURIComponent(deliveryId)}`)}
onPing={pingWebhookHook}
/>
) : (
<WebhookPage
Expand Down Expand Up @@ -1900,12 +1918,29 @@ function LazyLoadSentinel({ noun, hasMore, loading, onLoadMore }: { noun: string
return <div ref={ref} className="flex min-h-10 items-center justify-center border-t border-border px-3 py-2 text-xs font-medium text-muted" aria-live="polite">{loading ? `Loading more ${noun}...` : `Scroll for more ${noun}`}</div>;
}

function WebhookHookDetailPage({ data, loading, error, onBack, onRefresh, onViewHook, onViewDelivery = () => undefined }: { data?: WebhookHookDetailResponse; loading: boolean; error: Error | null; onBack: () => void; onRefresh: () => void; onViewHook: (hookId: string) => void; onViewDelivery?: (deliveryId: string) => void }) {
function WebhookHookDetailPage({ data, loading, error, onBack, onRefresh, onViewHook, onViewDelivery = () => undefined, onPing }: { data?: WebhookHookDetailResponse; loading: boolean; error: Error | null; onBack: () => void; onRefresh: () => void; onViewHook: (hookId: string) => void; onViewDelivery?: (deliveryId: string) => void; onPing?: (hookId: string) => Promise<string> }) {
const [pingPending, setPingPending] = React.useState(false);
const [pingResult, setPingResult] = React.useState("");
const [pingError, setPingError] = React.useState("");
if (loading) return <WebhookLoadingState text="Loading hook detail…" />;
if (error) return <div className="grid gap-3"><Banner tone="error" text={error.message} /><button type="button" className="w-fit rounded border border-border px-3 py-2 text-sm font-semibold" onClick={onBack}>Back to hooks</button></div>;
if (!data) return <EmptyState text="Hook detail is not available." />;
const hook = data.hook;
return <section className="grid gap-4"><div className="flex flex-wrap items-start justify-between gap-3"><div><button type="button" className="mb-2 inline-flex items-center gap-2 text-sm font-semibold text-primary hover:underline" onClick={onBack}><ArrowLeft className="h-4 w-4" aria-hidden />Back to hooks</button><h2 className="text-xl font-semibold">{hook.target}</h2><p className="font-mono text-xs text-muted">{hook.target_type} webhook #{hook.id}</p></div><div className="flex gap-2"><RefreshButton onClick={onRefresh} />{hook.admin_url ? <a className="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm font-semibold hover:bg-slate-50" href={safeExternalUrl(hook.admin_url)} target="_blank" rel="noreferrer"><ExternalLink className="h-4 w-4" aria-hidden />Open in GitHub</a> : null}</div></div><div className="grid grid-cols-2 gap-3 lg:grid-cols-4"><Metric title="State" value={hook.status} icon={<Activity className="h-5 w-5" />} /><Metric title="Deliveries" value={data.stats.deliveries} icon={<Link className="h-5 w-5" />} /><Metric title="Retries" value={data.stats.duplicates} icon={<RefreshCw className="h-5 w-5" />} /><Metric title="Unsupported" value={data.stats.unsupported} icon={<AlertTriangle className="h-5 w-5" />} /></div><Panel title="Sanitized configuration"><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"><MiniStat label="Active" value={hook.active ? "yes" : "no"} /><MiniStat label="Content type" value={hook.content_type ?? "unknown"} /><MiniStat label="SSL verification" value={hook.ssl_verify === null || hook.ssl_verify === undefined ? "unknown" : hook.ssl_verify ? "enabled" : "disabled"} /><MiniStat label="Destination URL" value={hook.delivery_url ?? "unknown"} /><MiniStat label="GitHub created" value={hook.github_created_at ?? "unknown"} /><MiniStat label="GitHub updated" value={hook.github_updated_at ?? "unknown"} /><MiniStat label="Last ping" value={hook.last_ping_at ?? "never"} /><MiniStat label="Last delivery" value={hook.last_event_at ?? "never"} /><MiniStat label="Last result" value={hook.last_result ?? "unknown"} /></div><div className="mt-3 rounded-md border border-border p-3"><div className="text-xs font-semibold text-muted">Subscribed events</div><div className="mt-2 flex flex-wrap gap-1.5">{hook.events.length ? hook.events.map((event) => <span key={event} className="rounded border border-border bg-slate-50 px-2 py-1 font-mono text-xs">{event}</span>) : <span className="text-sm text-muted">No event snapshot; redeliver a ping.</span>}</div></div></Panel><Panel title="Latest observed delivery"><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"><MiniStat label="Event" value={hook.last_event_name ? `${hook.last_event_name}${hook.last_action ? ` · ${hook.last_action}` : ""}` : "none"} /><MiniStat label="Repository" value={hook.last_repository ?? "none"} /><MiniStat label="Delivery ID" value={hook.last_delivery_id ?? "none"} /></div></Panel><Panel title="Recent deliveries">{data.recent_deliveries.length ? <WebhookDeliveriesTable deliveries={data.recent_deliveries} onViewHook={onViewHook} onViewDelivery={onViewDelivery} /> : <EmptyState text="This hook has no retained deliveries." />}</Panel></section>;
const requestPing = async () => {
if (!onPing || pingPending) return;
setPingPending(true);
setPingResult("");
setPingError("");
try {
setPingResult(await onPing(hook.id));
} catch (err) {
setPingError(err instanceof Error ? err.message : String(err));
} finally {
setPingPending(false);
}
};
const recentActions = data.recent_actions ?? [];
return <section className="grid gap-4"><div className="flex flex-wrap items-start justify-between gap-3"><div><button type="button" className="mb-2 inline-flex items-center gap-2 text-sm font-semibold text-primary hover:underline" onClick={onBack}><ArrowLeft className="h-4 w-4" aria-hidden />Back to hooks</button><h2 className="text-xl font-semibold">{hook.target}</h2><p className="font-mono text-xs text-muted">{hook.target_type} webhook #{hook.id}</p></div><div className="flex flex-wrap gap-2">{hook.ping_url && onPing ? <button type="button" className="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm font-semibold hover:bg-slate-50 disabled:cursor-wait disabled:opacity-60" disabled={pingPending} onClick={() => void requestPing()}><Activity className={cn("h-4 w-4", pingPending && "animate-pulse")} aria-hidden />{pingPending ? "Sending ping…" : "Send ping"}</button> : null}<RefreshButton onClick={onRefresh} />{hook.admin_url ? <a className="inline-flex h-8 items-center gap-2 rounded-md border border-border px-3 text-sm font-semibold hover:bg-slate-50" href={safeExternalUrl(hook.admin_url)} target="_blank" rel="noreferrer"><ExternalLink className="h-4 w-4" aria-hidden />Open in GitHub</a> : null}</div></div>{pingError ? <Banner tone="error" text={pingError} /> : null}{pingResult ? <div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">{pingResult}</div> : null}<div className="grid grid-cols-2 gap-3 lg:grid-cols-4"><Metric title="State" value={hook.status} icon={<Activity className="h-5 w-5" />} /><Metric title="Deliveries" value={data.stats.deliveries} icon={<Link className="h-5 w-5" />} /><Metric title="Retries" value={data.stats.duplicates} icon={<RefreshCw className="h-5 w-5" />} /><Metric title="Unsupported" value={data.stats.unsupported} icon={<AlertTriangle className="h-5 w-5" />} /></div><Panel title="Sanitized configuration"><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"><MiniStat label="Active" value={hook.active ? "yes" : "no"} /><MiniStat label="Content type" value={hook.content_type ?? "unknown"} /><MiniStat label="SSL verification" value={hook.ssl_verify === null || hook.ssl_verify === undefined ? "unknown" : hook.ssl_verify ? "enabled" : "disabled"} /><MiniStat label="Destination URL" value={hook.delivery_url ?? "unknown"} /><MiniStat label="GitHub created" value={hook.github_created_at ?? "unknown"} /><MiniStat label="GitHub updated" value={hook.github_updated_at ?? "unknown"} /><MiniStat label="Last ping" value={hook.last_ping_at ?? "never"} /><MiniStat label="Last delivery" value={hook.last_event_at ?? "never"} /><MiniStat label="Last result" value={hook.last_result ?? "unknown"} /></div><div className="mt-3 rounded-md border border-border p-3"><div className="text-xs font-semibold text-muted">Subscribed events</div><div className="mt-2 flex flex-wrap gap-1.5">{hook.events.length ? hook.events.map((event) => <span key={event} className="rounded border border-border bg-slate-50 px-2 py-1 font-mono text-xs">{event}</span>) : <span className="text-sm text-muted">No event snapshot; send a ping to refresh it.</span>}</div></div></Panel><Panel title="Latest observed delivery"><div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"><MiniStat label="Event" value={hook.last_event_name ? `${hook.last_event_name}${hook.last_action ? ` · ${hook.last_action}` : ""}` : "none"} /><MiniStat label="Repository" value={hook.last_repository ?? "none"} /><MiniStat label="Delivery ID" value={hook.last_delivery_id ?? "none"} /></div></Panel><Panel title="Recent administrative actions">{recentActions.length ? <div className="divide-y divide-border rounded-md border border-border">{recentActions.map((action) => <div key={action.id} className="flex flex-wrap items-center justify-between gap-2 px-3 py-2 text-sm"><div><span className="font-semibold">{action.action}</span><span className="ml-2 text-muted">by @{action.actor}</span></div><span className="font-mono text-xs text-muted">{action.status} · {action.created_at}</span></div>)}</div> : <EmptyState text="No administrative actions recorded for this hook." />}</Panel><Panel title="Recent deliveries">{data.recent_deliveries.length ? <WebhookDeliveriesTable deliveries={data.recent_deliveries} onViewHook={onViewHook} onViewDelivery={onViewDelivery} /> : <EmptyState text="This hook has no retained deliveries." />}</Panel></section>;
}

function WebhookDeliveryDetailPage({ data, loading, error, onBack, onRefresh, onViewHook, onViewJob }: { data?: WebhookDeliveryDetailResponse; loading: boolean; error: Error | null; onBack: () => void; onRefresh: () => void; onViewHook: (hookId: string) => void; onViewJob: (jobId: number) => void }) {
Expand Down
10 changes: 9 additions & 1 deletion docs/ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,15 @@ split so opening the overview does not load hook inventory or delivery history:
configuration (never the secret or raw payload), while later deliveries
update its latest event, repository, delivery ID, and result.
- `GET /api/webhooks/github/hooks/{hook_id}` returns the sanitized hook detail,
aggregate retained-delivery counts, and its most recent deliveries.
aggregate retained-delivery counts, its most recent deliveries, and recent
audited administrative actions.
- `POST /api/webhooks/github/hooks/{hook_id}/ping` lets a dashboard administrator
ask GitHub to send a fresh `ping`. The server validates the stored URL against
the exact `api.github.com` organization or repository hook path before calling
`gh api`; browser OAuth credentials are never reused. The operational `gh`
identity needs `Organization hooks: write` or `Repository webhooks: write`.
Requested, successful, and failed actions are recorded with the administrator
login, while CLI failure details remain hidden from the HTTP response.
- `GET /api/webhooks/github/deliveries?limit=<1-100>&cursor=<opaque>` returns a
cursor-paginated delivery page. Optional `hook_id`, `event_name`, `repository`,
and `result` filters are applied server-side; every row includes the known
Expand Down
6 changes: 6 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,12 @@ a job URL to open the dashboard with that job's session, worklog, activity feed
and GitHub links selected. The UI is a Vite + React + TypeScript app styled with
Tailwind and operational components, using TanStack Query for API state and
Recharts for percentile charts.
Webhook administrators can request a configuration refresh from a hook detail
page. This runs `gh api --method POST <validated-ping-path>` under the dashboard
service identity, so `gh auth status` must report an account with organization
or repository webhook write permission. Each request and result is retained in
`webhook_hook_actions`; raw CLI errors are audited but are not returned to the
browser.
The process activity API and dashboard distinguish live executor process state,
persisted process activity, semantic job progress, and visible transcript/output
progress so operators can tell whether a running job is merely alive or actually
Expand Down
Loading
Loading