diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 6b63ac1..108410b 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { Project, ProjectWebhook, UpdateProjectInput, Task, ChecklistItem, AgentMessage, DeepLink, Priority, Status, OwnerType, RecurrenceStatus, RegistryUser, RegistryAgent, AgentStatus, TaskActivity, TaskReminder, TaskRun, WorkloadRow, Label, TaskLabel, EstimatesSummary, EstimatesGroupBy, Comment, RelationType, TaskRelation, TaskRelationsResponse, GithubBackfillResult, GithubBackfillState } from './types'; +import type { Project, ProjectWebhook, UpdateProjectInput, Task, ChecklistItem, AgentMessage, DeepLink, Priority, Status, OwnerType, RecurrenceStatus, RegistryUser, RegistryAgent, AgentStatus, TaskActivity, TaskReminder, TaskRun, WorkloadRow, Label, TaskLabel, EstimatesSummary, EstimatesGroupBy, Comment, RelationType, TaskRelation, TaskRelationsResponse, GithubBackfillResult, GithubBackfillState, GithubRetryFailedResult, GithubRetryResult, GithubSyncEventsResponse, GithubSyncStatus } from './types'; import { getAuthHeaders } from './auth'; const BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001'; @@ -35,6 +35,22 @@ export const runGithubBackfill = (projectId: string, data: { includeComments?: boolean; }) => req(`/api/projects/${projectId}/github-sync/backfill`, { method: 'POST', body: JSON.stringify(data) }); +export const getGithubSyncEvents = (projectId: string, params: { status?: GithubSyncStatus; limit?: number } = {}) => { + const qs = new URLSearchParams(); + if (params.status) qs.set('status', params.status); + if (params.limit !== undefined) qs.set('limit', String(params.limit)); + const suffix = qs.toString() ? `?${qs}` : ''; + return req(`/api/projects/${projectId}/github-sync${suffix}`); +}; +export const retryFailedGithubSyncEvents = (projectId: string, limit?: number) => + req(`/api/projects/${projectId}/github-sync/retry-failed`, { + method: 'POST', + body: JSON.stringify({ limit }), + }); +export const retryGithubWebhookDelivery = (projectId: string, deliveryId: string) => + req(`/api/projects/${projectId}/github-sync/webhook-deliveries/${encodeURIComponent(deliveryId)}/retry`, { method: 'POST' }); +export const retryGithubOutboundEvent = (projectId: string, eventId: string) => + req(`/api/projects/${projectId}/github-sync/outbound-events/${encodeURIComponent(eventId)}/retry`, { method: 'POST' }); export const getProjectEstimatesSummary = (projectId: string, groupBy: EstimatesGroupBy = 'status') => req(`/api/projects/${projectId}/estimates/summary?groupBy=${groupBy}`); export const exportYaml = (projectId: string) => diff --git a/frontend/src/components/ProjectSettings.tsx b/frontend/src/components/ProjectSettings.tsx index c5c896c..7f733b3 100644 --- a/frontend/src/components/ProjectSettings.tsx +++ b/frontend/src/components/ProjectSettings.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import type { EstimateUnit, GithubBackfillResult, GithubBackfillState, Label, Project, ProjectWebhook, UpdateProjectInput } from '../types'; +import type { EstimateUnit, GithubBackfillResult, GithubBackfillState, GithubOutboundEvent, GithubRetryFailedResult, GithubSyncEventsResponse, GithubSyncStatus, GithubWebhookDelivery, Label, Project, ProjectWebhook, UpdateProjectInput } from '../types'; import { labelTextColor } from '../utils'; import * as api from '../api'; @@ -400,6 +400,7 @@ export function ProjectSettings({ project, taskCount, onClose, onUpdate, onDelet )} +
@@ -518,6 +519,264 @@ function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: ); } +function GithubSyncEvents({ projectId }: { projectId: string }) { + const [status, setStatus] = useState('open'); + const [limit, setLimit] = useState(50); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [busyId, setBusyId] = useState(null); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + + const load = async () => { + setLoading(true); + setError(null); + try { + const safeLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, Math.round(limit))) : 50; + setLimit(safeLimit); + setData(await api.getGithubSyncEvents(projectId, { status, limit: safeLimit })); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to load GitHub sync events'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void load(); }, [projectId, status]); + + const retryFailed = async () => { + setBusyId('retry-failed'); + setMessage(null); + setError(null); + try { + const result = await api.retryFailedGithubSyncEvents(projectId, limit); + setMessage(formatRetryFailed(result)); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to retry GitHub sync failures'); + } finally { + setBusyId(null); + } + }; + + const retryDelivery = async (deliveryId: string) => { + setBusyId(`delivery:${deliveryId}`); + setMessage(null); + setError(null); + try { + await api.retryGithubWebhookDelivery(projectId, deliveryId); + setMessage('Webhook delivery retry completed.'); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to retry webhook delivery'); + } finally { + setBusyId(null); + } + }; + + const retryOutbound = async (eventId: string) => { + setBusyId(`outbound:${eventId}`); + setMessage(null); + setError(null); + try { + await api.retryGithubOutboundEvent(projectId, eventId); + setMessage('Outbound event retry completed.'); + await load(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Failed to retry outbound event'); + } finally { + setBusyId(null); + } + }; + + return ( +
+
+
+
Sync events
+
Recent inbound webhook deliveries and outbound GitHub writes.
+
+
+ + setLimit(Number(e.target.value))} + onBlur={() => void load()} + className={`${inputClass} w-20 text-xs py-1.5`} + aria-label="GitHub sync event limit" + /> + + +
+
+ + {data && ( +
+ + + + +
+ )} + + {error && ( +
{error}
+ )} + {message && ( +
{message}
+ )} + + {loading ? ( +
Loading…
+ ) : data && data.webhookDeliveries.length === 0 && data.outboundEvents.length === 0 ? ( +
No GitHub sync events match this filter.
+ ) : data && ( +
+ + {data.webhookDeliveries.map(row => ( + retryDelivery(row.delivery_id)} + /> + ))} + + + {data.outboundEvents.map(row => ( + retryOutbound(row.id)} + /> + ))} + +
+ )} +
+ ); +} + +function SyncSummaryTile({ label, value, failed, total }: { label: string; value: number; failed?: number; total?: number }) { + return ( +
+
{label}
+
+ {value} + {total !== undefined && / {total}} + {failed !== undefined && failed > 0 && {failed} failed} +
+
+ ); +} + +function SyncEventSection({ title, empty, rows, children }: { title: string; empty: string; rows: T[]; children: React.ReactNode }) { + return ( +
+
{title}
+ {rows.length === 0 ? ( +
{empty}
+ ) : ( +
{children}
+ )} +
+ ); +} + +function WebhookDeliveryRow({ row, busy, onRetry }: { row: GithubWebhookDelivery; busy: boolean; onRetry: () => void }) { + return ( +
+
+ {syncBadge(row)} +
+
+ {row.event}{row.action ? `.${row.action}` : ''} from {row.repo} +
+
+ {formatDate(row.received_at)} · attempts {row.attempts} · delivery {row.delivery_id} +
+ {row.error &&
{row.error}
} +
+ {!row.processed && ( + + )} +
+
+ ); +} + +function OutboundEventRow({ row, busy, onRetry }: { row: GithubOutboundEvent; busy: boolean; onRetry: () => void }) { + return ( +
+
+ {syncBadge(row)} +
+
+ {row.action} to {row.gh_url} +
+
+ {formatDate(row.created_at)} · attempts {row.attempts} · task {row.task_id || 'unknown'} +
+ {row.error &&
{row.error}
} +
+ {!row.processed && ( + + )} +
+
+ ); +} + +function syncBadge(row: { processed: boolean; error: string | null }) { + if (row.processed) { + return done; + } + if (row.error) { + return failed; + } + return open; +} + +function formatDate(value: string | null) { + if (!value) return 'Never'; + return new Date(value).toLocaleString(); +} + +function formatRetryFailed(result: GithubRetryFailedResult) { + const inbound = result.webhookDeliveries; + const outbound = result.outboundEvents; + return `Retried failed events. Inbound: ${inbound.processed} processed, ${inbound.failed} failed, ${inbound.skipped} skipped. Outbound: ${outbound.processed} processed, ${outbound.failed} failed, ${outbound.skipped} skipped.`; +} + function Webhooks({ projectId }: { projectId: string }) { const [hooks, setHooks] = useState([]); const [loading, setLoading] = useState(true); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 99c53f9..6e394fb 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -84,6 +84,72 @@ export interface GithubBackfillResult { }; } +export type GithubSyncStatus = 'open' | 'failed' | 'processed' | 'all'; + +export interface GithubSyncSummaryBucket { + total: number; + processed: number; + open: number; + failed: number; +} + +export interface GithubWebhookDelivery { + delivery_id: string; + event: string; + action: string | null; + repo: string; + attempts: number; + processed: boolean; + processed_at: string | null; + error: string | null; + received_at: string; +} + +export interface GithubOutboundEvent { + id: string; + task_id: string | null; + gh_url: string; + action: string; + attempts: number; + processed: boolean; + processed_at: string | null; + error: string | null; + created_at: string; +} + +export interface GithubSyncEventsResponse { + projectId: string; + status: GithubSyncStatus; + limit: number; + summary: { + webhookDeliveries: GithubSyncSummaryBucket; + outboundEvents: GithubSyncSummaryBucket; + }; + webhookDeliveries: GithubWebhookDelivery[]; + outboundEvents: GithubOutboundEvent[]; +} + +export interface GithubRetryFailedResult { + projectId: string; + limit: number; + webhookDeliveries: { + processed: number; + failed: number; + skipped: number; + }; + outboundEvents: { + processed: number; + failed: number; + skipped: number; + }; +} + +export interface GithubRetryResult { + processed?: boolean; + skipped?: boolean; + reason?: string; +} + export interface Label { id: string; project_id: string;