Files
inter-hub/Web/View/ApiDashboard/Show.hs
Bernd Worsch 3cac021213
Some checks failed
Test / test (push) Has been cancelled
feat(WP-0010): IHF Phase 9 — External API Surface and Consumer SDKs
Delivers the full Phase 9 external API layer:

- Versioned REST API (/api/v2/) with OpenAPI 3.1 spec; enum arrays for
  widget_type, event_type, annotation category drawn live from registry tables
- OAuth 2.0 client credentials flow (/api/v2/token); hub:*:write scopes
  gated on active HubCapabilityManifest FK
- API key management: SHA256-hashed tokens, key_prefix for display,
  one-time reveal on creation, revocation support
- TypeScript and Python consumer SDKs generated from registry tables
  (/api/v2/sdk/ihf-client.ts, /api/v2/sdk/ihf-client.py)
- Webhook delivery: HMAC-SHA256 signing, append-only webhook_deliveries,
  fire-and-forget dispatch via forkIO, 3-retry logic
- Admin API dashboard with 24h stats (request count, error rate, last seen)
- Rate limiting (per-minute) and daily quota enforcement via api_request_log
- Schema migration: api_consumers, api_keys, webhook_subscriptions (CHECK
  constraint on 6 framework lifecycle topics), webhook_deliveries
  (append-only trigger), api_request_log
- ARCHITECTURE-LAYERS.md scorecard: 3.34 → 3.41 (approaching Strong)
- contracts/functional/interaction-reporting-v1.md extended with Phase 9
  endpoint catalogue and 422 validation error format

GAAF: no bare TEXT discriminators; webhook event_type uses CHECK constraint
over 6 allowed framework lifecycle topic strings (not widget event types).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:52:20 +00:00

76 lines
3.1 KiB
Haskell
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
module Web.View.ApiDashboard.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Data.Maybe (fromMaybe)
data ConsumerStats = ConsumerStats
{ consumer :: !ApiConsumer
, requests24h :: !Int
, errorRate :: !Double -- fraction 0..1
, lastSeen :: !(Maybe UTCTime)
}
data ShowView = ShowView { stats :: ![ConsumerStats] }
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold">API Usage Dashboard</h1>
<p class="text-sm text-gray-500 mt-1">Per-consumer request metrics (last 24 hours)</p>
</div>
<a href={ApiConsumersAction} class="text-sm text-gray-500 hover:text-gray-700"> Consumers</a>
</div>
{if null stats
then [hsx|<p class="text-sm text-gray-400">No API activity yet.</p>|]
else statsTable}
|]
where
statsTable = [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-600">Consumer</th>
<th class="text-right px-4 py-3 font-medium text-gray-600">Req (24h)</th>
<th class="text-right px-4 py-3 font-medium text-gray-600">Error Rate</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Last Seen</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Manifest</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach stats renderRow}
</tbody>
</table>
</div>
|]
renderRow ConsumerStats { .. } = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium">
<a href={ShowApiConsumerAction consumer.id} class="text-indigo-600 hover:underline">
{consumer.name}
</a>
</td>
<td class="px-4 py-3 text-right">{show requests24h}</td>
<td class="px-4 py-3 text-right">
<span class={errorClass errorRate}>
{formatErrorRate errorRate}%
</span>
</td>
<td class="px-4 py-3 text-gray-500 text-xs">
{maybe "never" show lastSeen}
</td>
<td class="px-4 py-3 text-gray-500">
{if isJust consumer.hubCapabilityManifestId then "" else "" :: Text}
</td>
</tr>
|]
errorClass rate
| rate > 0.1 = "text-red-600 font-medium" :: Text
| rate > 0.02 = "text-amber-600"
| otherwise = "text-gray-600"
formatErrorRate rate = show (round (rate * 100) :: Int)