Skip to content
Merged
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
12 changes: 10 additions & 2 deletions dashboard/src/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,27 +142,33 @@ describe("dashboard routing and API query helpers", () => {
});

it("renders the webhook status exported by the backend", () => {
render(<WebhookPage summary={{ mode: "shadow", configured: true, receipts: { observed: 7 }, duplicate_deliveries: 2, cross_source_matches: 3 }} timeseries={[]} section="overview" summaryLoading={false} sectionLoading={false} loadingMore={false} hasMore={false} deliveryFilters={{ hook_id: "", event_name: "", repository: "", result: "" }} error={null} onSectionChange={vi.fn()} onLoadMore={vi.fn()} onDeliveryFiltersChange={vi.fn()} onViewHook={vi.fn()} onRefresh={vi.fn()} />);
render(<WebhookPage summary={{ mode: "shadow", configured: true, receipts: { observed: 7 }, duplicate_deliveries: 2, cross_source_matches: 3, totals: { hooks: 143, deliveries: 912 } }} timeseries={[]} section="overview" summaryLoading={false} sectionLoading={false} loadingMore={false} hasMore={false} deliveryFilters={{ hook_id: "", event_name: "", repository: "", result: "" }} error={null} onSectionChange={vi.fn()} onLoadMore={vi.fn()} onDeliveryFiltersChange={vi.fn()} onViewHook={vi.fn()} onRefresh={vi.fn()} />);

expect(screen.getByRole("heading", { name: "GitHub webhooks" })).toBeInTheDocument();
expect(screen.getByText("shadow")).toBeInTheDocument();
expect(screen.getByText("observed")).toBeInTheDocument();
expect(screen.getAllByText("7").length).toBeGreaterThan(0);
expect(screen.getByRole("tab", { name: "Hooks (143 total)" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Deliveries (912 total)" })).toBeInTheDocument();
});

it("navigates webhook hook inventory and delivery details", async () => {
const user = userEvent.setup();
const onSectionChange = vi.fn();
const onViewHook = vi.fn();
const summary = { mode: "shadow", configured: true, receipts: { observed: 7 }, duplicate_deliveries: 2, cross_source_matches: 3 };
const summary = { mode: "shadow", configured: true, receipts: { observed: 7 }, duplicate_deliveries: 2, cross_source_matches: 3, totals: { hooks: 101, deliveries: 912 } };
const hooks = [{ id: "42", target: "gisce", target_type: "organization" as const, active: true, events: ["issue_comment"], status: "receiving" as const, last_ping_at: "2026-10-02T10:00:00Z", last_event_at: "2026-10-02T10:05:00Z" }];
const deliveries = [{ delivery_id: "delivery-1", created_at: "2026-10-02T10:05:00Z", hook_id: "42", hook: { id: "42", target: "gisce", target_type: "organization" as const }, event_name: "issue_comment", action: "created", repository: "gisce/github-agent-bridge", status: "observed" }];
const common = { summary, summaryLoading: false, sectionLoading: false, loadingMore: false, hasMore: false, deliveryFilters: { hook_id: "", event_name: "", repository: "", result: "" }, error: null, onSectionChange, onLoadMore: vi.fn(), onDeliveryFiltersChange: vi.fn(), onViewHook, onRefresh: vi.fn() };
const { rerender } = render(<WebhookPage {...common} section="overview" />);

expect(screen.getByRole("tab", { name: "Hooks (101 total)" })).toBeInTheDocument();

await user.click(screen.getByRole("tab", { name: /Hooks/ }));
expect(onSectionChange).toHaveBeenCalledWith("hooks");
rerender(<WebhookPage {...common} hooks={hooks} section="hooks" />);
expect(screen.getByRole("tab", { name: "Hooks (101 total)" })).toBeInTheDocument();
expect(screen.getByTestId("lazy-scroll-hooks")).toHaveClass("max-h-[640px]", "overflow-auto");
expect(screen.getByText("organization · #42")).toBeInTheDocument();
expect(screen.getByText("receiving")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "gisce" }));
Expand All @@ -171,6 +177,7 @@ describe("dashboard routing and API query helpers", () => {
await user.click(screen.getByRole("tab", { name: /Deliveries/ }));
expect(onSectionChange).toHaveBeenCalledWith("deliveries");
rerender(<WebhookPage {...common} deliveries={deliveries} section="deliveries" />);
expect(screen.getByTestId("lazy-scroll-deliveries")).toHaveClass("max-h-[640px]", "overflow-auto");
expect(screen.getAllByText("issue_comment · created").length).toBeGreaterThan(0);
expect(screen.getByText("gisce/github-agent-bridge")).toBeInTheDocument();
expect(screen.getByText("#42")).toBeInTheDocument();
Expand Down Expand Up @@ -511,6 +518,7 @@ describe("status badges", () => {
);

expect(screen.getByRole("columnheader", { name: "Status" }).parentElement).toHaveClass("sticky", "top-0", "z-10");
expect(screen.getByTestId("lazy-scroll-jobs")).toHaveClass("max-h-[640px]", "overflow-auto");
expect(screen.getByRole("columnheader", { name: "Job" })).toBeInTheDocument();
expect(screen.queryByRole("columnheader", { name: "Model" })).not.toBeInTheDocument();
expect(screen.queryByText("openai/gpt-5.4-mini · medium")).not.toBeInTheDocument();
Expand Down
109 changes: 69 additions & 40 deletions dashboard/src/main.tsx

Large diffs are not rendered by default.

8 changes: 6 additions & 2 deletions docs/ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,12 @@ payload and never creates a queue job. The monitoring API is split so opening
the overview does not load hook inventory or delivery history:

- `GET /api/webhooks/github/summary` returns mode, receipt counts, retries, and
cross-source matches. The previous `/status` path remains a summary-only
compatibility alias.
cross-source coverage (`both`, `imap_only`, `webhook_only`) with an explicit
IMAP denominator and mean matching delay. The previous `/status` path remains
a summary-only compatibility alias.
- `GET /api/webhooks/github/exceptions` returns a bounded operator queue for
IMAP-only, webhook-only, and unmatchable retained events. It never returns raw
payload or comment bodies.
- `GET /api/webhooks/github/timeseries?from=<iso>&to=<iso>&bucket=day|hour`
returns only the requested interval. Daily ranges are capped at 366 days and
hourly ranges at 31 days; omitted bounds default to the configured retention
Expand Down
50 changes: 49 additions & 1 deletion src/github_agent_bridge/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -792,8 +792,20 @@ def github_webhook_shadow_summary(_: dict[str, Any] = Depends(current_admin_prof
" WHERE w.event_key IS NOT NULL AND EXISTS ("
" SELECT 1 FROM ingest_receipts i WHERE i.event_key=w.event_key"
" )"
"), coverage AS ("
" SELECT "
" (SELECT COUNT(DISTINCT event_key) FROM ingest_receipts WHERE source='email' AND event_key IS NOT NULL) imap_events,"
" (SELECT COUNT(DISTINCT event_key) FROM webhook_shadow_receipts WHERE event_key IS NOT NULL) webhook_events,"
" COUNT(DISTINCT i.event_key) both_events,"
" AVG(ABS((julianday(w.created_at)-julianday(i.created_at))*86400000.0)) match_delay_ms"
" FROM ingest_receipts i JOIN webhook_shadow_receipts w ON w.event_key=i.event_key"
" WHERE i.source='email'"
"), inventory AS ("
" SELECT (SELECT COUNT(*) FROM webhook_hooks) hooks,"
" (SELECT COUNT(*) FROM webhook_shadow_receipts) deliveries"
") SELECT COALESCE(observed,0),COALESCE(unsupported,0),"
"duplicate_deliveries,matches FROM receipt_summary CROSS JOIN cross_source"
"duplicate_deliveries,matches,imap_events,webhook_events,both_events,match_delay_ms,hooks,deliveries "
"FROM receipt_summary CROSS JOIN cross_source CROSS JOIN coverage CROSS JOIN inventory"
).fetchone()
counts = {
name: count
Expand All @@ -806,8 +818,44 @@ def github_webhook_shadow_summary(_: dict[str, Any] = Depends(current_admin_prof
"receipts": counts,
"duplicate_deliveries": row[2],
"cross_source_matches": row[3],
"totals": {"hooks": row[8], "deliveries": row[9]},
"coverage": {
"both": row[6],
"imap_only": max(row[4] - row[6], 0),
"webhook_only": max(row[5] - row[6], 0),
"imap_eligible": row[4],
"ratio": (row[6] / row[4]) if row[4] else None,
"mean_match_delay_ms": round(row[7], 1) if row[7] is not None else None,
},
}

@app.get("/api/webhooks/github/exceptions")
def github_webhook_shadow_exceptions(
limit: int = Query(50, ge=1, le=100),
_: dict[str, Any] = Depends(current_admin_profile),
) -> dict[str, Any]:
"""Return bounded, actionable shadow divergences without loading all receipts."""
ensure_webhook_schema()
with sqlite3.connect(config.db) as con:
con.row_factory = sqlite3.Row
rows = con.execute(
"WITH candidates AS ("
" SELECT 'imap_only' kind,i.event_key,i.source_key reference,i.created_at,NULL repository "
" FROM ingest_receipts i WHERE i.event_key IS NOT NULL AND i.source='email' AND NOT EXISTS ("
" SELECT 1 FROM webhook_shadow_receipts w WHERE w.event_key=i.event_key"
" ) UNION ALL "
" SELECT 'webhook_only',w.event_key,w.delivery_id,w.created_at,w.repository "
" FROM webhook_shadow_receipts w WHERE w.event_key IS NOT NULL AND NOT EXISTS ("
" SELECT 1 FROM ingest_receipts i WHERE i.event_key=w.event_key"
" ) UNION ALL "
" SELECT 'unmatchable',NULL,w.delivery_id,w.created_at,w.repository "
" FROM webhook_shadow_receipts w WHERE w.event_key IS NULL"
") SELECT kind,event_key,reference,created_at,repository FROM candidates "
"ORDER BY created_at DESC LIMIT ?",
(limit,),
).fetchall()
return {"exceptions": [dict(row) for row in rows]}

@app.get("/api/webhooks/github/timeseries")
def github_webhook_shadow_timeseries(
from_value: str | None = Query(None, alias="from"),
Expand Down

This file was deleted.

187 changes: 187 additions & 0 deletions src/github_agent_bridge/dashboard_static/assets/index-B5yiYDzv.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

187 changes: 0 additions & 187 deletions src/github_agent_bridge/dashboard_static/assets/index-BhOrC4LA.js

This file was deleted.

4 changes: 2 additions & 2 deletions src/github_agent_bridge/dashboard_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GitHub Agent Bridge Dashboard</title>
<script type="module" crossorigin src="/assets/index-BhOrC4LA.js"></script>
<script type="module" crossorigin src="/assets/index-B5yiYDzv.js"></script>
<link rel="modulepreload" crossorigin href="/assets/charts-SqBiqy9C.js">
<link rel="stylesheet" crossorigin href="/assets/index-B1f_MgKy.css">
<link rel="stylesheet" crossorigin href="/assets/index-BDe9F9HC.css">
</head>
<body>
<div id="root"></div>
Expand Down
10 changes: 10 additions & 0 deletions tests/test_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ def test_webhook_status_requires_dashboard_admin(tmp_path):
"/api/webhooks/github/timeseries",
"/api/webhooks/github/hooks",
"/api/webhooks/github/deliveries",
"/api/webhooks/github/exceptions",
)
assert {client.get(path).status_code for path in paths} == {401}
assert client.get("/api/webhooks/github/hooks/42").status_code == 401
Expand Down Expand Up @@ -197,6 +198,11 @@ def test_webhook_monitoring_endpoints_keep_summary_light_and_return_real_data(tm
"mode": "shadow", "configured": True,
"receipts": {"observed": 1, "unsupported": 1},
"duplicate_deliveries": 0, "cross_source_matches": 0,
"totals": {"hooks": 1, "deliveries": 2},
"coverage": {
"both": 0, "imap_only": 0, "webhook_only": 1,
"imap_eligible": 0, "ratio": None, "mean_match_delay_ms": None,
},
}
assert client.get("/api/webhooks/github/status").json() == summary

Expand Down Expand Up @@ -260,6 +266,10 @@ def test_webhook_monitoring_endpoints_keep_summary_light_and_return_real_data(tm
assert timeseries["bucket"] == "hour"
assert timeseries["points"][0]["observed"] == 1
assert timeseries["points"][0]["unsupported"] == 1
exceptions = client.get("/api/webhooks/github/exceptions").json()["exceptions"]
assert {(item["kind"], item["reference"]) for item in exceptions} == {
("unmatchable", "ping-1"), ("webhook_only", "delivery-1"),
}


def test_webhook_monitoring_rejects_unbounded_ranges_and_invalid_cursors(tmp_path):
Expand Down
Loading