diff --git a/internal/core/hook_core.go b/internal/core/hook_core.go index 5457319..0488a69 100644 --- a/internal/core/hook_core.go +++ b/internal/core/hook_core.go @@ -14,6 +14,7 @@ type TokenGenerator func() string type HookRepository interface { CreateHook(ctx context.Context, token string) (domain.Hook, error) GetHookByToken(ctx context.Context, token string) (domain.Hook, error) + ListHooks(ctx context.Context) ([]domain.Hook, error) CreateWebhookRequest(ctx context.Context, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, error) ListWebhookRequests(ctx context.Context, hookID int64) ([]domain.WebhookRequest, error) GetHookResponse(ctx context.Context, hookID int64) (domain.HookResponse, error) @@ -36,6 +37,10 @@ func NewHook(repo HookRepository, generateToken TokenGenerator) *Hook { } } +func (s *Hook) ListHooks(ctx context.Context) ([]domain.Hook, error) { + return s.repo.ListHooks(ctx) +} + func (s *Hook) CreateHook(ctx context.Context, token string) (domain.Hook, error) { if token == "" { token = s.generateToken() diff --git a/internal/repos/hook.go b/internal/repos/hook.go index 5cf2973..99d39d0 100644 --- a/internal/repos/hook.go +++ b/internal/repos/hook.go @@ -78,6 +78,20 @@ func (r *Hook) ListWebhookRequests(ctx context.Context, hookID int64) ([]domain. return result, nil } +func (r *Hook) ListHooks(ctx context.Context) ([]domain.Hook, error) { + rows, err := r.q.ListHooks(ctx) + if err != nil { + return nil, err + } + + result := make([]domain.Hook, len(rows)) + for i, row := range rows { + result[i] = toDomainHook(row) + } + + return result, nil +} + func (r *Hook) GetHookResponse(ctx context.Context, hookID int64) (domain.HookResponse, error) { row, err := r.q.GetHookResponseByHookID(ctx, hookID) if err != nil { diff --git a/internal/server/contract.go b/internal/server/contract.go index 4a993ec..11f5f73 100644 --- a/internal/server/contract.go +++ b/internal/server/contract.go @@ -19,6 +19,14 @@ type CreateEndpointResponseContract struct { URL string `json:"url"` } +type EndpointListItemContract struct { + ID int64 `json:"id"` + Token string `json:"token"` + Name string `json:"name,omitempty"` + URL string `json:"url"` + CreatedAt time.Time `json:"createdAt"` +} + type WebhookRequestContract struct { ID int64 `json:"id"` HookID int64 `json:"hookId"` diff --git a/internal/server/handler.go b/internal/server/handler.go index 9f91d69..1364e48 100644 --- a/internal/server/handler.go +++ b/internal/server/handler.go @@ -15,6 +15,7 @@ import ( const DefaultMaxBodySize int64 = 5 << 20 // 5MB type HookService interface { + ListHooks(ctx context.Context) ([]domain.Hook, error) CreateHook(ctx context.Context, token string) (domain.Hook, error) ReceiveWebhook(ctx context.Context, token string, params domain.CreateWebhookRequestParams) (domain.WebhookRequest, domain.HookResponse, error) ListWebhookRequests(ctx context.Context, token string) ([]domain.WebhookRequest, error) @@ -54,6 +55,7 @@ func NewHook(deps *HookDeps) *Hook { } func (h *Hook) RegisterRoutes() { + h.deps.Mux.HandleFunc("GET /api/endpoints", h.ListEndpoints) h.deps.Mux.HandleFunc("POST /api/endpoints", h.CreateEndpoint) h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/requests", h.ListRequests) h.deps.Mux.HandleFunc("GET /api/endpoints/{token}/events", h.StreamEvents) @@ -62,6 +64,35 @@ func (h *Hook) RegisterRoutes() { h.deps.Mux.HandleFunc("/r/{token}", h.ReceiveWebhook) } +func (h *Hook) ListEndpoints(w http.ResponseWriter, r *http.Request) { + hooks, err := h.deps.Service.ListHooks(r.Context()) + if err != nil { + slog.Error("list endpoints", "err", err) + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + contracts := make([]EndpointListItemContract, len(hooks)) + for i, hook := range hooks { + contracts[i] = EndpointListItemContract{ + ID: hook.ID, + Token: hook.Token, + Name: hook.Name, + URL: h.deps.Opts.BaseURL + "/r/" + hook.Token, + CreatedAt: hook.CreatedAt, + } + } + + data, err := json.Marshal(contracts) + if err != nil { + slog.Error("marshal endpoints", "err", err) + SendError(w, http.StatusInternalServerError, ErrInternal) + return + } + + SendSuccess(w, http.StatusOK, data) +} + func (h *Hook) CreateEndpoint(w http.ResponseWriter, r *http.Request) { if h.readOnly(w) { return diff --git a/internal/store/query/hooks.sql b/internal/store/query/hooks.sql index 5672d69..49ea469 100644 --- a/internal/store/query/hooks.sql +++ b/internal/store/query/hooks.sql @@ -66,6 +66,11 @@ ON CONFLICT (hook_id) DO UPDATE SET updated_at = CURRENT_TIMESTAMP RETURNING id, hook_id, status_code, headers, body, created_at, updated_at; +-- name: ListHooks :many +SELECT id, token, name, created_at, updated_at +FROM hooks +ORDER BY created_at DESC; + -- name: GetHookResponseByHookID :one SELECT id, hook_id, status_code, headers, body, created_at, updated_at FROM hook_responses diff --git a/internal/store/sqlc/hooks.sql.go b/internal/store/sqlc/hooks.sql.go index 3d7a6c5..8a792b6 100644 --- a/internal/store/sqlc/hooks.sql.go +++ b/internal/store/sqlc/hooks.sql.go @@ -153,6 +153,40 @@ func (q *Queries) GetHookResponseByHookID(ctx context.Context, hookID int64) (Ho return i, err } +const listHooks = `-- name: ListHooks :many +SELECT id, token, name, created_at, updated_at +FROM hooks +ORDER BY created_at DESC` + +func (q *Queries) ListHooks(ctx context.Context) ([]Hook, error) { + rows, err := q.db.QueryContext(ctx, listHooks) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Hook + for rows.Next() { + var i Hook + if err := rows.Scan( + &i.ID, + &i.Token, + &i.Name, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const listWebhookRequestsByHookID = `-- name: ListWebhookRequestsByHookID :many SELECT id, hook_id, method, path, query, headers, body, remote_addr, content_type, body_size, received_at FROM webhook_requests diff --git a/internal/web/ui/index.html b/internal/web/ui/index.html index a734f00..2f6f448 100644 --- a/internal/web/ui/index.html +++ b/internal/web/ui/index.html @@ -82,14 +82,9 @@

Welcome to Webhix

diff --git a/internal/web/ui/src/app/main.ts b/internal/web/ui/src/app/main.ts index 19cdbba..7a5c692 100644 --- a/internal/web/ui/src/app/main.ts +++ b/internal/web/ui/src/app/main.ts @@ -3,8 +3,10 @@ import './styles.css'; import { connectEvents, createEndpoint, + fetchEndpoints, fetchRequests, } from '../features/endpoint-session/api/endpoint-api'; +import type { Endpoint } from '../features/endpoint-session/api/endpoint-api'; import { getElements } from './dom'; import { renderRequestList, refreshRelativeTimes } from '../widgets/request-list/request-list'; import { renderSelectedDetail, showPlaceholder } from '../widgets/request-detail/request-detail'; @@ -26,6 +28,7 @@ const state = createInitialState(); const elements = getElements(); let eventSource: EventSource | null = null; let toastTimer: ReturnType | undefined; +let currentToken: string | null = null; init(); @@ -82,6 +85,7 @@ function loadToken(): void { } function activateToken(token: string): void { + currentToken = token; resetForToken(state, token); const url = new URL(location.href); @@ -89,9 +93,7 @@ function activateToken(token: string): void { history.replaceState(null, '', url.toString()); elements.pillURL.textContent = `${location.origin}/r/${token}`; - document.getElementById('endpointName')?.replaceChildren(document.createTextNode(token)); document.getElementById('endpointTitle')?.replaceChildren(document.createTextNode(token)); - document.getElementById('endpointPath')?.replaceChildren(document.createTextNode(`/r/${token}`)); elements.pillArea.classList.remove('hidden'); elements.overlay.classList.add('hidden'); elements.mainArea.classList.remove('hidden'); @@ -99,9 +101,44 @@ function activateToken(token: string): void { renderRequestList(elements, state); showPlaceholder(elements); void loadHistory(token); + void loadEndpoints(); connectSSE(token); } +async function loadEndpoints(): Promise { + try { + const eps = await fetchEndpoints(); + renderEndpointsList(eps); + } catch { + // silently fail + } +} + +function renderEndpointsList(endpoints: Endpoint[]): void { + const list = document.getElementById('endpointsList'); + const countBadge = document.getElementById('endpointsPanelCount'); + if (!list) return; + + if (countBadge) countBadge.textContent = String(endpoints.length); + + list.innerHTML = ''; + for (const ep of endpoints) { + const btn = document.createElement('button'); + btn.className = 'endpoint-card' + (ep.token === currentToken ? ' active' : ''); + btn.dataset.token = ep.token; + + const label = document.createElement('strong'); + label.textContent = ep.name || ep.token; + + const path = document.createElement('small'); + path.textContent = `/r/${ep.token}`; + + btn.append(label, path); + btn.addEventListener('click', () => activateToken(ep.token)); + list.appendChild(btn); + } +} + async function createNewEndpoint(): Promise { try { const token = await createEndpoint(); diff --git a/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts b/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts index d5e513a..0fd6ab2 100644 --- a/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts +++ b/internal/web/ui/src/features/endpoint-session/api/endpoint-api.ts @@ -1,5 +1,20 @@ import type { ApiResponse, WebhookRequest } from '../../../entities/request/model/types'; +export interface Endpoint { + id: number; + token: string; + name?: string; + url: string; + createdAt: string; +} + +export async function fetchEndpoints(): Promise { + const response = await fetch('/api/endpoints'); + const json = (await response.json()) as ApiResponse; + if (!json.success) return []; + return json.body ?? []; +} + export async function createEndpoint(): Promise { const response = await fetch('/api/endpoints', { method: 'POST',