generated from coulomb/repo-seed
fix(WP-0014): pre-flight compilation fixes, Tailwind pipeline, and admin seed
A2 — Compilation fixes: - Remove inline FK constraints from Schema.sql; IHP schema compiler cannot parse them. Add 1744329600-restore-fk-constraints.sql migration to restore referential integrity at the DB level. - Rename `#label` → `#label_` throughout to avoid clash with Haskell built-in. - Fix `hub.id == hid` UUID comparisons to use `toUUID hub.id`. - Replace non-existent `setStatus`/`respondJson` calls with `renderJsonWithStatusCode` throughout Api controllers. - Fix qualified package import for `cryptohash-sha256` in Auth.hs. - Add `CanSelect (Text, Text)` instance in Helper.View. - Refactor HSX inline lambdas to named helper functions in 100+ views (GHC cannot infer types for anonymous functions inside quasi-quoted HSX). - Fix missing imports (IHP.QueryBuilder, IHP.Fetch, Web.Routes, Only, etc.) across helpers and controllers. - Remove duplicate `diffUTCTime` definition in BottleneckDetector. - Change `createEventForHub` return type from `IO ResponseReceived` to `IO ()`. - Seed type-registry vocabulary via 1744502400-seed-type-registries.sql (moved from Schema.sql where IHP does not execute INSERT statements). A3 — Tailwind build pipeline: - Add `tailwindcss` to flake.nix native packages. - Uncomment `tailwind.exec` process in devenv shell config. - Add tailwind/tailwind.config.js (scans Web/View/**/*.hs). - Add tailwind/app.css with @tailwind directives. A4 — Admin user seed: - Add 1744416000-seed-admin-user.sql: inserts admin@inter-hub.local with bcrypt-hashed password admin1234! (cost 10). - Add .env.example documenting all required environment variables and default admin credentials. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
import Application.Helper.View (adapterStatusBadge)
|
||||
import Data.List (nub, sortBy)
|
||||
import Data.Ord (comparing, Down(..))
|
||||
@@ -23,7 +24,7 @@ instance View AdapterCompatibilityDashboardView where
|
||||
<h1 class="text-2xl font-semibold">Adapter Compatibility Dashboard</h1>
|
||||
<p class="text-sm text-gray-500">{hub.name}</p>
|
||||
</div>
|
||||
<a href={ShowHubAction { hubId = hub.id }}
|
||||
<a href={ShowHubAction (hub.id)}
|
||||
class="text-sm text-indigo-600 hover:underline">← Hub</a>
|
||||
</div>
|
||||
|
||||
@@ -71,17 +72,11 @@ instance View AdapterCompatibilityDashboardView where
|
||||
<div class="flex gap-6 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500 mr-1">Envelope:</span>
|
||||
{forEach envelopes (\e -> [hsx|
|
||||
<a href={ShowEnvelopeEmissionContractAction { envelopeEmissionContractId = e.id }}
|
||||
class="font-mono text-indigo-600 hover:underline mr-2">v{e.contractVersion}</a>
|
||||
|])}
|
||||
{forEach envelopes renderEnvelopeLink}
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500 mr-1">Reporting:</span>
|
||||
{forEach reportings (\r -> [hsx|
|
||||
<a href={ShowInteractionReportingContractAction { interactionReportingContractId = r.id }}
|
||||
class="font-mono text-indigo-600 hover:underline mr-2">v{r.contractVersion}</a>
|
||||
|])}
|
||||
{forEach reportings renderReportingLink}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,19 +87,7 @@ instance View AdapterCompatibilityDashboardView where
|
||||
Unassigned Widgets
|
||||
<span class="ml-1 text-xs text-gray-400">(no adapter_spec_id)</span>
|
||||
</h2>
|
||||
{if null unassignedWidgets
|
||||
then [hsx|<p class="text-sm text-gray-400">All widgets have adapter assignments.</p>|]
|
||||
else [hsx|
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
{forEach unassignedWidgets (\w -> [hsx|
|
||||
<div>
|
||||
<a href={ShowWidgetAction { widgetId = w.id }}
|
||||
class="text-indigo-600 hover:underline">{w.name}</a>
|
||||
<span class="text-xs text-gray-400 ml-2">{w.widgetType}</span>
|
||||
</div>
|
||||
|])}
|
||||
</div>
|
||||
|]}
|
||||
{renderUnassignedWidgets unassignedWidgets}
|
||||
</div>
|
||||
|
||||
<!-- Panel 5: Stale adapters -->
|
||||
@@ -112,23 +95,7 @@ instance View AdapterCompatibilityDashboardView where
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">
|
||||
Active Adapter Specs
|
||||
</h2>
|
||||
{if null activeSpecs
|
||||
then [hsx|<p class="text-sm text-gray-400">No active adapter specs.</p>|]
|
||||
else [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Adapter</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Framework</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Widgets</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{forEach activeSpecs renderSpecRow}
|
||||
</tbody>
|
||||
</table>
|
||||
|]}
|
||||
{renderActiveSpecsTable activeSpecs}
|
||||
</div>
|
||||
|]
|
||||
where
|
||||
@@ -149,13 +116,31 @@ instance View AdapterCompatibilityDashboardView where
|
||||
in sortBy (comparing (Down . snd))
|
||||
[ (sid, length (filter (== sid) assigned)) | sid <- specIds ]
|
||||
|
||||
renderActiveSpecsTable :: [WidgetAdapterSpec] -> Html
|
||||
renderActiveSpecsTable [] = [hsx|<p class="text-sm text-gray-400">No active adapter specs.</p>|]
|
||||
renderActiveSpecsTable ss = [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Adapter</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Framework</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Widgets</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{forEach ss renderSpecRow}
|
||||
</tbody>
|
||||
</table>
|
||||
|]
|
||||
|
||||
renderSpecRow :: WidgetAdapterSpec -> Html
|
||||
renderSpecRow s =
|
||||
let widgetCount = length (filter (\w -> w.adapterSpecId == Just s.id) widgets)
|
||||
in [hsx|
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2">
|
||||
<a href={ShowWidgetAdapterSpecAction { widgetAdapterSpecId = s.id }}
|
||||
<a href={ShowWidgetAdapterSpecAction (s.id)}
|
||||
class="text-indigo-600 hover:underline">{s.name}</a>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
@@ -170,6 +155,35 @@ instance View AdapterCompatibilityDashboardView where
|
||||
</tr>
|
||||
|]
|
||||
|
||||
renderEnvelopeLink :: EnvelopeEmissionContract -> Html
|
||||
renderEnvelopeLink e = [hsx|
|
||||
<a href={ShowEnvelopeEmissionContractAction (e.id)}
|
||||
class="font-mono text-indigo-600 hover:underline mr-2">v{e.contractVersion}</a>
|
||||
|]
|
||||
|
||||
renderReportingLink :: InteractionReportingContract -> Html
|
||||
renderReportingLink r = [hsx|
|
||||
<a href={ShowInteractionReportingContractAction (r.id)}
|
||||
class="font-mono text-indigo-600 hover:underline mr-2">v{r.contractVersion}</a>
|
||||
|]
|
||||
|
||||
renderUnassignedWidgets :: [Widget] -> Html
|
||||
renderUnassignedWidgets [] = [hsx|<p class="text-sm text-gray-400">All widgets have adapter assignments.</p>|]
|
||||
renderUnassignedWidgets ws = [hsx|
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
{forEach ws renderUnassignedWidgetRow}
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderUnassignedWidgetRow :: Widget -> Html
|
||||
renderUnassignedWidgetRow w = [hsx|
|
||||
<div>
|
||||
<a href={ShowWidgetAction (w.id)}
|
||||
class="text-indigo-600 hover:underline">{w.name}</a>
|
||||
<span class="text-xs text-gray-400 ml-2">{w.widgetType}</span>
|
||||
</div>
|
||||
|]
|
||||
|
||||
kpiCard :: Text -> Text -> Text -> Html
|
||||
kpiCard label value textClass = [hsx|
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
|
||||
data AgentAuditDashboardView = AgentAuditDashboardView
|
||||
{ hub :: !Hub
|
||||
@@ -19,7 +20,7 @@ instance View AgentAuditDashboardView where
|
||||
<h1 class="text-2xl font-semibold">Agent Audit Dashboard</h1>
|
||||
<p class="text-sm text-gray-500">{hub.name}</p>
|
||||
</div>
|
||||
<a href={ShowHubAction { hubId = hub.id }}
|
||||
<a href={ShowHubAction (hub.id)}
|
||||
class="text-sm text-indigo-600 hover:underline">← Hub</a>
|
||||
</div>
|
||||
|
||||
@@ -35,14 +36,7 @@ instance View AgentAuditDashboardView where
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-5">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">Proposals by Type</h2>
|
||||
<div class="flex gap-4 flex-wrap">
|
||||
{forEach allTypes (\t ->
|
||||
let cnt = length (filter (\p -> p.proposalType == t) proposals)
|
||||
in [hsx|
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={typeBadge t <> " text-xs px-2 py-0.5 rounded font-medium"}>{t}</span>
|
||||
<span class="text-sm font-semibold text-gray-700">{show cnt}</span>
|
||||
</div>
|
||||
|])}
|
||||
{forEach allTypes (renderTypeCount proposals)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,15 +45,7 @@ instance View AgentAuditDashboardView where
|
||||
<div class="px-5 py-3 border-b border-gray-100 bg-yellow-50">
|
||||
<h2 class="text-sm font-semibold text-yellow-800">Unreviewed Queue ({show pendingCount})</h2>
|
||||
</div>
|
||||
{if null pending
|
||||
then [hsx|<p class="text-sm text-gray-400 px-5 py-4">No pending proposals.</p>|]
|
||||
else [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{forEach (sortByCreatedAt pending) renderQueueRow}
|
||||
</tbody>
|
||||
</table>
|
||||
|]}
|
||||
{renderPendingQueue pending}
|
||||
</div>
|
||||
|
||||
<!-- Recent proposals (last 20) -->
|
||||
@@ -90,20 +76,11 @@ instance View AgentAuditDashboardView where
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left px-3 py-1 text-gray-500">Model</th>
|
||||
{forEach allTypes (\t -> [hsx|
|
||||
<th class="px-3 py-1 text-gray-500">{t}</th>
|
||||
|])}
|
||||
{forEach allTypes renderTypeHeader}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{forEach allModels (\m -> [hsx|
|
||||
<tr class="border-t border-gray-100">
|
||||
<td class="px-3 py-1 font-mono text-gray-600">{m}</td>
|
||||
{forEach allTypes (\t ->
|
||||
let cnt = length (filter (\p -> p.modelRef == m && p.proposalType == t) proposals)
|
||||
in [hsx|<td class="px-3 py-1 text-center text-gray-700">{if cnt == 0 then "—" else show cnt}</td>|])}
|
||||
</tr>
|
||||
|])}
|
||||
{forEach allModels (renderModelRow allTypes proposals)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -121,6 +98,23 @@ instance View AgentAuditDashboardView where
|
||||
allTypes = ["summary", "requirement_draft", "duplicate_flag", "policy_flag", "impl_proposal"]
|
||||
allModels = nub (map (.modelRef) proposals)
|
||||
|
||||
renderTypeHeader :: Text -> Html
|
||||
renderTypeHeader t = [hsx|<th class="px-3 py-1 text-gray-500">{t}</th>|]
|
||||
|
||||
renderModelRow :: [Text] -> [AgentProposal] -> Text -> Html
|
||||
renderModelRow types props m = [hsx|
|
||||
<tr class="border-t border-gray-100">
|
||||
<td class="px-3 py-1 font-mono text-gray-600">{m}</td>
|
||||
{forEach types (renderMatrixCell props m)}
|
||||
</tr>
|
||||
|]
|
||||
|
||||
renderMatrixCell :: [AgentProposal] -> Text -> Text -> Html
|
||||
renderMatrixCell props m t =
|
||||
let cnt = length (filter (\p -> p.modelRef == m && p.proposalType == t) props)
|
||||
display = if cnt == 0 then "—" else show cnt
|
||||
in [hsx|<td class="px-3 py-1 text-center text-gray-700">{display}</td>|]
|
||||
|
||||
kpiCard :: Text -> Text -> Text -> Html
|
||||
kpiCard label value colorClass = [hsx|
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||
@@ -139,7 +133,7 @@ renderQueueRow p = [hsx|
|
||||
</td>
|
||||
<td class="px-4 py-2 text-gray-400 text-xs">{show p.createdAt}</td>
|
||||
<td class="px-4 py-2">
|
||||
<a href={ShowAgentProposalAction { agentProposalId = p.id }}
|
||||
<a href={ShowAgentProposalAction (p.id)}
|
||||
class="text-xs text-indigo-600 hover:underline">Review →</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -149,7 +143,7 @@ renderRecentRow :: [Widget] -> AgentProposal -> Html
|
||||
renderRecentRow widgets p = [hsx|
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-2">
|
||||
<a href={ShowAgentProposalAction { agentProposalId = p.id }}
|
||||
<a href={ShowAgentProposalAction (p.id)}
|
||||
class={typeBadge p.proposalType <> " text-xs px-2 py-0.5 rounded font-medium"}>
|
||||
{p.proposalType}
|
||||
</a>
|
||||
@@ -165,6 +159,26 @@ renderRecentRow widgets p = [hsx|
|
||||
</tr>
|
||||
|]
|
||||
|
||||
renderTypeCount :: [AgentProposal] -> Text -> Html
|
||||
renderTypeCount proposals t =
|
||||
let cnt = length (filter (\p -> p.proposalType == t) proposals)
|
||||
in [hsx|
|
||||
<div class="flex items-center gap-2">
|
||||
<span class={typeBadge t <> " text-xs px-2 py-0.5 rounded font-medium"}>{t}</span>
|
||||
<span class="text-sm font-semibold text-gray-700">{show cnt}</span>
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderPendingQueue :: [AgentProposal] -> Html
|
||||
renderPendingQueue [] = [hsx|<p class="text-sm text-gray-400 px-5 py-4">No pending proposals.</p>|]
|
||||
renderPendingQueue pending = [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{forEach (sortByCreatedAt pending) renderQueueRow}
|
||||
</tbody>
|
||||
</table>
|
||||
|]
|
||||
|
||||
widgetName :: [Widget] -> Maybe (Id Widget) -> Text
|
||||
widgetName _ Nothing = "—"
|
||||
widgetName widgets (Just wid) = maybe "—" (.name) (find (\w -> w.id == wid) widgets)
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
|
||||
data AntifragilityDashboardView = AntifragilityDashboardView
|
||||
{ hub :: !Hub
|
||||
@@ -24,22 +25,22 @@ instance View AntifragilityDashboardView where
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-1">
|
||||
<a href={HubsAction} class="hover:text-gray-700">Hubs</a>
|
||||
<span>/</span>
|
||||
<a href={ShowHubAction { hubId = hub.id }} class="hover:text-gray-700">{hub.name}</a>
|
||||
<a href={ShowHubAction (hub.id)} class="hover:text-gray-700">{hub.name}</a>
|
||||
<span>/</span>
|
||||
<span>Antifragility</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold">Antifragility Dashboard — {hub.name}</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href={TriageDashboardAction { hubId = hub.id }}
|
||||
<a href={TriageDashboardAction (hub.id)}
|
||||
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||
Triage
|
||||
</a>
|
||||
<a href={GovernanceDashboardAction { hubId = hub.id }}
|
||||
<a href={GovernanceDashboardAction (hub.id)}
|
||||
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||
Governance
|
||||
</a>
|
||||
<a href={ShowHubAction { hubId = hub.id }}
|
||||
<a href={ShowHubAction (hub.id)}
|
||||
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||
Hub
|
||||
</a>
|
||||
@@ -67,14 +68,7 @@ instance View AntifragilityDashboardView where
|
||||
</div>
|
||||
|
||||
<!-- Regression alerts -->
|
||||
{if null regressionWidgetIds then mempty else [hsx|
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg px-6 py-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-red-700 mb-3">⚠ Regression Alerts</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{forEach regressedWidgets renderRegressedBadge}
|
||||
</div>
|
||||
</div>
|
||||
|]}
|
||||
{if null regressionWidgetIds then mempty else renderRegressionAlerts regressedWidgets}
|
||||
|
||||
<!-- Open gaps: decisions with impl refs but no deployment -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5 mb-6">
|
||||
@@ -84,56 +78,19 @@ instance View AntifragilityDashboardView where
|
||||
(decisions with impl refs but no deployment recorded)
|
||||
</span>
|
||||
</h2>
|
||||
{if null openGaps
|
||||
then [hsx|<p class="text-sm text-gray-400">All decisions with impl refs have deployments.</p>|]
|
||||
else [hsx|
|
||||
<div class="space-y-1">
|
||||
{forEach openGaps renderGapRow}
|
||||
</div>
|
||||
|]}
|
||||
{renderOpenGaps openGaps}
|
||||
</div>
|
||||
|
||||
<!-- Recent deployments -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">Recent Deployments</h2>
|
||||
{if null recentDeploys
|
||||
then [hsx|<p class="text-sm text-gray-400">No deployments yet.</p>|]
|
||||
else [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Version</th>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Decision</th>
|
||||
<th class="text-right py-2 text-xs font-medium text-gray-500">Signals</th>
|
||||
<th class="text-right py-2 text-xs font-medium text-gray-500">Eval</th>
|
||||
<th class="text-right py-2 text-xs font-medium text-gray-500">Deployed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{forEach recentDeploys (renderDeployRow allDecisions allSignals allEvaluations)}
|
||||
</tbody>
|
||||
</table>
|
||||
|]}
|
||||
{renderRecentDeploysSection recentDeploys allDecisions allSignals allEvaluations}
|
||||
</div>
|
||||
|
||||
<!-- Recurrence leaderboard -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">Recurrence Leaderboard</h2>
|
||||
{if null recurrenceLeaderboard
|
||||
then [hsx|<p class="text-sm text-gray-400">No recurring widgets detected.</p>|]
|
||||
else [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Widget</th>
|
||||
<th class="text-right py-2 text-xs font-medium text-gray-500">Cycles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{forEach recurrenceLeaderboard (renderRecurrenceRow widgets)}
|
||||
</tbody>
|
||||
</table>
|
||||
|]}
|
||||
{renderRecurrenceSection recurrenceLeaderboard widgets}
|
||||
</div>
|
||||
|]
|
||||
where
|
||||
@@ -160,7 +117,7 @@ sortByDesc f = sortBy (\a b -> compare (f b) (f a))
|
||||
|
||||
renderRegressedBadge :: Widget -> Html
|
||||
renderRegressedBadge w = [hsx|
|
||||
<a href={ShowWidgetAction { widgetId = w.id }}
|
||||
<a href={ShowWidgetAction (w.id)}
|
||||
class="text-xs bg-red-100 text-red-800 border border-red-300 rounded px-2 py-1 hover:bg-red-200">
|
||||
{w.name}
|
||||
</a>
|
||||
@@ -169,7 +126,7 @@ renderRegressedBadge w = [hsx|
|
||||
renderGapRow :: DecisionRecord -> Html
|
||||
renderGapRow d = [hsx|
|
||||
<div class="flex items-center justify-between py-1.5 text-sm">
|
||||
<a href={ShowDecisionRecordAction { decisionRecordId = d.id }}
|
||||
<a href={ShowDecisionRecordAction (d.id)}
|
||||
class="text-indigo-600 hover:text-indigo-800">{d.title}</a>
|
||||
<span class={outcomeClass d.outcome <> " text-xs px-2 py-0.5 rounded font-medium"}>
|
||||
{d.outcome}
|
||||
@@ -181,7 +138,7 @@ renderDeployRow :: [DecisionRecord] -> [OutcomeSignal] -> [ChangeEvaluation] ->
|
||||
renderDeployRow decisions signals evals dr = [hsx|
|
||||
<tr>
|
||||
<td class="py-2 pr-4">
|
||||
<a href={ShowDeploymentRecordAction { deploymentRecordId = dr.id }}
|
||||
<a href={ShowDeploymentRecordAction (dr.id)}
|
||||
class="font-mono text-indigo-600 hover:text-indigo-800">{dr.versionRef}</a>
|
||||
</td>
|
||||
<td class="py-2 pr-4 text-gray-600">{decisionTitle}</td>
|
||||
@@ -189,7 +146,7 @@ renderDeployRow decisions signals evals dr = [hsx|
|
||||
{renderSignalSummary drSignals}
|
||||
</td>
|
||||
<td class="py-2 pr-4 text-right">
|
||||
{maybe [hsx|<span class="text-gray-400 text-xs">—</span>|] renderEvalBadge mScore}
|
||||
{maybe noEvalBadge renderEvalBadge mScore}
|
||||
</td>
|
||||
<td class="py-2 text-right text-xs text-gray-400">{show dr.deployedAt}</td>
|
||||
</tr>
|
||||
@@ -203,9 +160,7 @@ renderSignalSummary :: [OutcomeSignal] -> Html
|
||||
renderSignalSummary [] = [hsx|<span class="text-gray-400 text-xs">—</span>|]
|
||||
renderSignalSummary signals = [hsx|
|
||||
<div class="flex gap-1 justify-end">
|
||||
{forEach (take 3 signals) (\s -> [hsx|
|
||||
<span class={signalDot s.signalType}></span>
|
||||
|])}
|
||||
{forEach (take 3 signals) renderSignalDot}
|
||||
</div>
|
||||
|]
|
||||
|
||||
@@ -227,7 +182,7 @@ renderRecurrenceRow :: [Widget] -> (Id Widget, Int) -> Html
|
||||
renderRecurrenceRow widgets (wid, count) = [hsx|
|
||||
<tr>
|
||||
<td class="py-2">
|
||||
{maybe [hsx|<span class="text-gray-500">—</span>|] renderWidgetLink mWidget}
|
||||
{maybe noWidgetSpan renderWidgetLink mWidget}
|
||||
</td>
|
||||
<td class="py-2 text-right">
|
||||
<span class="text-sm font-semibold text-yellow-700">⟳ {show count}</span>
|
||||
@@ -239,10 +194,72 @@ renderRecurrenceRow widgets (wid, count) = [hsx|
|
||||
|
||||
renderWidgetLink :: Widget -> Html
|
||||
renderWidgetLink w = [hsx|
|
||||
<a href={ShowWidgetAction { widgetId = w.id }}
|
||||
<a href={ShowWidgetAction (w.id)}
|
||||
class="text-indigo-600 hover:text-indigo-800">{w.name}</a>
|
||||
|]
|
||||
|
||||
renderRegressionAlerts :: [Widget] -> Html
|
||||
renderRegressionAlerts ws = [hsx|
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg px-6 py-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-red-700 mb-3">⚠ Regression Alerts</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{forEach ws renderRegressedBadge}
|
||||
</div>
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderOpenGaps :: [DecisionRecord] -> Html
|
||||
renderOpenGaps [] = [hsx|<p class="text-sm text-gray-400">All decisions with impl refs have deployments.</p>|]
|
||||
renderOpenGaps gaps = [hsx|
|
||||
<div class="space-y-1">
|
||||
{forEach gaps renderGapRow}
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderRecentDeploysSection :: [DeploymentRecord] -> [DecisionRecord] -> [OutcomeSignal] -> [ChangeEvaluation] -> Html
|
||||
renderRecentDeploysSection [] _ _ _ = [hsx|<p class="text-sm text-gray-400">No deployments yet.</p>|]
|
||||
renderRecentDeploysSection deploys decisions signals evals = [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Version</th>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Decision</th>
|
||||
<th class="text-right py-2 text-xs font-medium text-gray-500">Signals</th>
|
||||
<th class="text-right py-2 text-xs font-medium text-gray-500">Eval</th>
|
||||
<th class="text-right py-2 text-xs font-medium text-gray-500">Deployed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{forEach deploys (renderDeployRow decisions signals evals)}
|
||||
</tbody>
|
||||
</table>
|
||||
|]
|
||||
|
||||
renderRecurrenceSection :: [(Id Widget, Int)] -> [Widget] -> Html
|
||||
renderRecurrenceSection [] _ = [hsx|<p class="text-sm text-gray-400">No recurring widgets detected.</p>|]
|
||||
renderRecurrenceSection leaderboard widgets = [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Widget</th>
|
||||
<th class="text-right py-2 text-xs font-medium text-gray-500">Cycles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{forEach leaderboard (renderRecurrenceRow widgets)}
|
||||
</tbody>
|
||||
</table>
|
||||
|]
|
||||
|
||||
noEvalBadge :: Html
|
||||
noEvalBadge = [hsx|<span class="text-gray-400 text-xs">—</span>|]
|
||||
|
||||
noWidgetSpan :: Html
|
||||
noWidgetSpan = [hsx|<span class="text-gray-500">—</span>|]
|
||||
|
||||
renderSignalDot :: OutcomeSignal -> Html
|
||||
renderSignalDot s = [hsx|<span class={signalDot s.signalType}></span>|]
|
||||
|
||||
outcomeClass :: Text -> Text
|
||||
outcomeClass "accepted" = "bg-green-100 text-green-800"
|
||||
outcomeClass "rejected" = "bg-red-100 text-red-800"
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
import Data.Time.Clock (diffUTCTime, getCurrentTime)
|
||||
|
||||
data BottleneckDashboardView = BottleneckDashboardView
|
||||
@@ -20,11 +21,11 @@ instance View BottleneckDashboardView where
|
||||
<p class="text-sm text-gray-500">{hub.name}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href={DetectBottlenecksAction { hubId = hub.id }}
|
||||
<a href={DetectBottlenecksAction (hub.id)}
|
||||
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
|
||||
Detect
|
||||
</a>
|
||||
<a href={ShowHubAction { hubId = hub.id }}
|
||||
<a href={ShowHubAction (hub.id)}
|
||||
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||
← Hub
|
||||
</a>
|
||||
@@ -33,9 +34,7 @@ instance View BottleneckDashboardView where
|
||||
|
||||
{forEach stages renderStageSection}
|
||||
|
||||
{if null bottlenecks
|
||||
then [hsx|<p class="text-sm text-gray-400 mt-4">No active bottlenecks detected.</p>|]
|
||||
else mempty}
|
||||
{if null bottlenecks then noBottlenecksMsg else mempty}
|
||||
|]
|
||||
where
|
||||
stages = ["candidate", "requirement", "decision", "observation"] :: [Text]
|
||||
@@ -83,12 +82,15 @@ instance View BottleneckDashboardView where
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<a href={ResolveBottleneckAction { bottleneckRecordId = b.id }}
|
||||
<a href={ResolveBottleneckAction (b.id)}
|
||||
class="text-xs text-indigo-600 hover:underline">Resolve</a>
|
||||
</td>
|
||||
</tr>
|
||||
|]
|
||||
|
||||
noBottlenecksMsg :: Html
|
||||
noBottlenecksMsg = [hsx|<p class="text-sm text-gray-400 mt-4">No active bottlenecks detected.</p>|]
|
||||
|
||||
severityBadge :: Text -> Text
|
||||
severityBadge s = case s of
|
||||
"critical" -> "bg-red-100 text-red-800"
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
|
||||
data EditView = EditView { hub :: !Hub }
|
||||
|
||||
@@ -13,7 +14,7 @@ instance View EditView where
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-4">
|
||||
<a href={HubsAction} class="hover:text-gray-700">Hubs</a>
|
||||
<span>/</span>
|
||||
<a href={ShowHubAction { hubId = hub.id }} class="hover:text-gray-700">{hub.name}</a>
|
||||
<a href={ShowHubAction (hub.id)} class="hover:text-gray-700">{hub.name}</a>
|
||||
<span>/</span>
|
||||
<span>Edit</span>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
import Application.Helper.FrictionScore (scoreBand)
|
||||
|
||||
data FrictionHeatmapView = FrictionHeatmapView
|
||||
@@ -20,11 +21,11 @@ instance View FrictionHeatmapView where
|
||||
<p class="text-sm text-gray-500">{hub.name}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href={RecomputeFrictionAction { hubId = hub.id }}
|
||||
<a href={RecomputeFrictionAction (hub.id)}
|
||||
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
|
||||
Recompute
|
||||
</a>
|
||||
<a href={ShowHubAction { hubId = hub.id }}
|
||||
<a href={ShowHubAction (hub.id)}
|
||||
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||
← Hub
|
||||
</a>
|
||||
@@ -38,18 +39,20 @@ instance View FrictionHeatmapView where
|
||||
<span><span class="inline-block w-3 h-3 rounded bg-red-100 mr-1"></span>Critical (60+)</span>
|
||||
</div>
|
||||
|
||||
{if null widgets
|
||||
then [hsx|<p class="text-sm text-gray-400">No widgets in this hub.</p>|]
|
||||
else [hsx|
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{forEach widgets renderWidgetCard}
|
||||
</div>
|
||||
|]}
|
||||
{renderHeatmapGrid widgets}
|
||||
|]
|
||||
where
|
||||
scoreFor w = maybe 0 (.score) (find (\fs -> fs.widgetId == w.id) frictionScores)
|
||||
hasScore w = any (\fs -> fs.widgetId == w.id) frictionScores
|
||||
|
||||
renderHeatmapGrid :: [Widget] -> Html
|
||||
renderHeatmapGrid [] = [hsx|<p class="text-sm text-gray-400">No widgets in this hub.</p>|]
|
||||
renderHeatmapGrid ws = [hsx|
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
{forEach ws renderWidgetCard}
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderWidgetCard :: Widget -> Html
|
||||
renderWidgetCard w =
|
||||
let s = scoreFor w
|
||||
@@ -57,12 +60,14 @@ instance View FrictionHeatmapView where
|
||||
in [hsx|
|
||||
<div class={"rounded-lg border p-4 " <> band}>
|
||||
<div class="flex items-start justify-between">
|
||||
<a href={ShowWidgetAction { widgetId = w.id }}
|
||||
<a href={ShowWidgetAction (w.id)}
|
||||
class="font-medium text-sm hover:underline">{w.name}</a>
|
||||
{if hasScore w
|
||||
then [hsx|<span class="text-lg font-bold">{show s}</span>|]
|
||||
else [hsx|<span class="text-xs text-gray-400">–</span>|]}
|
||||
{renderScoreBadge (hasScore w) s}
|
||||
</div>
|
||||
<p class="text-xs mt-1 opacity-70">{w.widgetType}</p>
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderScoreBadge :: Bool -> Int -> Html
|
||||
renderScoreBadge True s = [hsx|<span class="text-lg font-bold">{show s}</span>|]
|
||||
renderScoreBadge False _ = [hsx|<span class="text-xs text-gray-400">–</span>|]
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
|
||||
data GovernanceDashboardView = GovernanceDashboardView
|
||||
{ hub :: !Hub
|
||||
@@ -23,22 +24,22 @@ instance View GovernanceDashboardView where
|
||||
<div class="flex items-center gap-2 text-sm text-gray-500 mb-1">
|
||||
<a href={HubsAction} class="hover:text-gray-700">Hubs</a>
|
||||
<span>/</span>
|
||||
<a href={ShowHubAction { hubId = hub.id }} class="hover:text-gray-700">{hub.name}</a>
|
||||
<a href={ShowHubAction (hub.id)} class="hover:text-gray-700">{hub.name}</a>
|
||||
<span>/</span>
|
||||
<span>Governance</span>
|
||||
</div>
|
||||
<h1 class="text-2xl font-semibold">Governance Dashboard — {hub.name}</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href={TriageDashboardAction { hubId = hub.id }}
|
||||
<a href={TriageDashboardAction (hub.id)}
|
||||
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||
Triage Dashboard
|
||||
</a>
|
||||
<a href={AntifragilityDashboardAction { hubId = hub.id }}
|
||||
<a href={AntifragilityDashboardAction (hub.id)}
|
||||
class="text-sm border border-green-300 text-green-700 px-3 py-1.5 rounded hover:bg-green-50">
|
||||
Antifragility
|
||||
</a>
|
||||
<a href={ShowHubAction { hubId = hub.id }}
|
||||
<a href={ShowHubAction (hub.id)}
|
||||
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||
Hub Overview
|
||||
</a>
|
||||
@@ -54,14 +55,7 @@ instance View GovernanceDashboardView where
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if null regressionWidgetIds then mempty else [hsx|
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg px-6 py-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-red-700 mb-2">⚠ Regressed Widgets</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{forEach regressedWidgets renderRegressedBadge}
|
||||
</div>
|
||||
</div>
|
||||
|]}
|
||||
{if null regressionWidgetIds then mempty else renderGovRegressionAlerts regressedWidgets}
|
||||
|
||||
<!-- Open requirements awaiting decision -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5 mb-6">
|
||||
@@ -71,31 +65,13 @@ instance View GovernanceDashboardView where
|
||||
({show (length awaitingDecision)} pending)
|
||||
</span>
|
||||
</h2>
|
||||
{if null awaitingDecision
|
||||
then [hsx|<p class="text-sm text-gray-400">All requirements have linked decisions.</p>|]
|
||||
else forEach awaitingDecision renderAwaitingReq}
|
||||
{renderAwaitingSection awaitingDecision}
|
||||
</div>
|
||||
|
||||
<!-- Recent decisions -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5 mb-6">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">Recent Decisions</h2>
|
||||
{if null recentDecisions
|
||||
then [hsx|<p class="text-sm text-gray-400">No decisions recorded yet.</p>|]
|
||||
else [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Title</th>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Outcome</th>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Source Widget</th>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Decided At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{forEach recentDecisions (renderDecisionRow allRequirements allCandidates widgets)}
|
||||
</tbody>
|
||||
</table>
|
||||
|]}
|
||||
{renderRecentDecisionsSection recentDecisions allRequirements allCandidates widgets}
|
||||
</div>
|
||||
|
||||
<!-- Traceability coverage per widget -->
|
||||
@@ -150,7 +126,7 @@ isAwaitingDecision decisions req =
|
||||
renderAwaitingReq :: Requirement -> Html
|
||||
renderAwaitingReq req = [hsx|
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-50 last:border-0">
|
||||
<a href={ShowRequirementAction { requirementId = req.id }}
|
||||
<a href={ShowRequirementAction (req.id)}
|
||||
class="text-sm text-indigo-600 hover:text-indigo-800">{req.title}</a>
|
||||
<span class="text-xs text-gray-400">{show req.createdAt}</span>
|
||||
</div>
|
||||
@@ -160,7 +136,7 @@ renderDecisionRow :: [Requirement] -> [RequirementCandidate] -> [Widget] -> Deci
|
||||
renderDecisionRow reqs candidates widgets dr = [hsx|
|
||||
<tr>
|
||||
<td class="py-2 pr-4">
|
||||
<a href={ShowDecisionRecordAction { decisionRecordId = dr.id }}
|
||||
<a href={ShowDecisionRecordAction (dr.id)}
|
||||
class="text-indigo-600 hover:text-indigo-800">{dr.title}</a>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
@@ -213,7 +189,7 @@ renderCoverageRow annotations candidates requirements decisions w = [hsx|
|
||||
|
||||
renderRegressedBadge :: Widget -> Html
|
||||
renderRegressedBadge w = [hsx|
|
||||
<a href={ShowWidgetAction { widgetId = w.id }}
|
||||
<a href={ShowWidgetAction (w.id)}
|
||||
class="text-xs bg-red-100 text-red-800 border border-red-300 rounded px-2 py-1 hover:bg-red-200">
|
||||
{w.name}
|
||||
</a>
|
||||
@@ -223,6 +199,38 @@ coverageMark :: Bool -> Html
|
||||
coverageMark True = [hsx|<span class="text-green-600 font-bold">✓</span>|]
|
||||
coverageMark False = [hsx|<span class="text-gray-300">✗</span>|]
|
||||
|
||||
renderGovRegressionAlerts :: [Widget] -> Html
|
||||
renderGovRegressionAlerts ws = [hsx|
|
||||
<div class="bg-red-50 border border-red-200 rounded-lg px-6 py-4 mb-6">
|
||||
<h2 class="text-sm font-semibold text-red-700 mb-3">Regression Alerts</h2>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{forEach ws renderRegressedBadge}
|
||||
</div>
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderAwaitingSection :: [Requirement] -> Html
|
||||
renderAwaitingSection [] = [hsx|<p class="text-sm text-gray-400">All requirements have linked decisions.</p>|]
|
||||
renderAwaitingSection reqs = [hsx|{forEach reqs renderAwaitingReq}|]
|
||||
|
||||
renderRecentDecisionsSection :: [DecisionRecord] -> [Requirement] -> [RequirementCandidate] -> [Widget] -> Html
|
||||
renderRecentDecisionsSection [] _ _ _ = [hsx|<p class="text-sm text-gray-400">No decisions recorded yet.</p>|]
|
||||
renderRecentDecisionsSection decisions reqs candidates ws = [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-gray-100">
|
||||
<tr>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Title</th>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Outcome</th>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Source Widget</th>
|
||||
<th class="text-left py-2 text-xs font-medium text-gray-500">Decided At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
{forEach decisions (renderDecisionRow reqs candidates ws)}
|
||||
</tbody>
|
||||
</table>
|
||||
|]
|
||||
|
||||
outcomeClass :: Text -> Text
|
||||
outcomeClass "accepted" = "bg-green-100 text-green-800"
|
||||
outcomeClass "rejected" = "bg-red-100 text-red-800"
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
import Application.Helper.HubHealth (healthScoreBadge)
|
||||
|
||||
data HubHealthHistoryView = HubHealthHistoryView
|
||||
@@ -19,57 +20,63 @@ instance View HubHealthHistoryView where
|
||||
<p class="text-sm text-gray-500">{hub.name}</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href={SnapshotHubHealthAction { hubId = hub.id }}
|
||||
<a href={SnapshotHubHealthAction (hub.id)}
|
||||
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
|
||||
Take Snapshot
|
||||
</a>
|
||||
<a href={ShowHubAction { hubId = hub.id }}
|
||||
<a href={ShowHubAction (hub.id)}
|
||||
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||
← Hub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{case snapshots of
|
||||
[] -> [hsx|<p class="text-sm text-gray-400">No snapshots yet. Take the first one.</p>|]
|
||||
(latest : _) -> [hsx|
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-6 flex items-center gap-6">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1">Current Health Score</p>
|
||||
<span class={"text-3xl font-bold px-3 py-1 rounded " <> healthScoreBadge latest.healthScore}>
|
||||
{show latest.healthScore}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div>Open candidates: <strong>{show latest.openCandidates}</strong></div>
|
||||
<div>Regressed widgets: <strong>{show latest.regressedWidgets}</strong></div>
|
||||
<div>Stale decisions: <strong>{show latest.staleDecisions}</strong></div>
|
||||
<div>Active bottlenecks: <strong>{show latest.activeBottlenecks}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|]}
|
||||
{renderLatestPanel snapshots}
|
||||
|
||||
{if null snapshots then mempty else [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 border-gray-200">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Score</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Open Cand.</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Regressed</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Stale Dec.</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Bottlenecks</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Taken At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{forEach snapshots renderRow}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|]}
|
||||
{renderSnapshotsTable snapshots}
|
||||
|]
|
||||
|
||||
renderLatestPanel :: [HubHealthSnapshot] -> Html
|
||||
renderLatestPanel [] = [hsx|<p class="text-sm text-gray-400">No snapshots yet. Take the first one.</p>|]
|
||||
renderLatestPanel (latest : _) = [hsx|
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-6 flex items-center gap-6">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 mb-1">Current Health Score</p>
|
||||
<span class={"text-3xl font-bold px-3 py-1 rounded " <> healthScoreBadge latest.healthScore}>
|
||||
{show latest.healthScore}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div>Open candidates: <strong>{show latest.openCandidates}</strong></div>
|
||||
<div>Regressed widgets: <strong>{show latest.regressedWidgets}</strong></div>
|
||||
<div>Stale decisions: <strong>{show latest.staleDecisions}</strong></div>
|
||||
<div>Active bottlenecks: <strong>{show latest.activeBottlenecks}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderSnapshotsTable :: [HubHealthSnapshot] -> Html
|
||||
renderSnapshotsTable [] = mempty
|
||||
renderSnapshotsTable snaps = [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 border-gray-200">
|
||||
<tr>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Score</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Open Cand.</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Regressed</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Stale Dec.</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Bottlenecks</th>
|
||||
<th class="text-left px-4 py-3 font-medium text-gray-700">Taken At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{forEach snaps renderRow}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderRow :: HubHealthSnapshot -> Html
|
||||
renderRow s = [hsx|
|
||||
<tr class="hover:bg-gray-50">
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
|
||||
data IndexView = IndexView { hubs :: ![Hub] }
|
||||
|
||||
@@ -44,7 +45,7 @@ renderHub :: Hub -> Html
|
||||
renderHub hub = [hsx|
|
||||
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
<a href={ShowHubAction { hubId = hub.id }}
|
||||
<a href={ShowHubAction (hub.id)}
|
||||
class="font-medium text-indigo-600 hover:text-indigo-800">
|
||||
{hub.name}
|
||||
</a>
|
||||
@@ -53,9 +54,9 @@ renderHub hub = [hsx|
|
||||
<td class="px-4 py-3 text-gray-500">{hub.domain}</td>
|
||||
<td class="px-4 py-3">{kindBadge hub.hubKind}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href={EditHubAction { hubId = hub.id }}
|
||||
<a href={EditHubAction (hub.id)}
|
||||
class="text-gray-500 hover:text-gray-700 text-xs mr-3">Edit</a>
|
||||
<a href={DeleteHubAction { hubId = hub.id }}
|
||||
<a href={DeleteHubAction (hub.id)}
|
||||
class="text-red-500 hover:text-red-700 text-xs"
|
||||
data-confirm="Delete this hub?">Delete</a>
|
||||
</td>
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
|
||||
data NewView = NewView { hub :: !Hub }
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
import Application.Helper.HubHealth (healthScoreBadge)
|
||||
import Application.Helper.FrictionScore (scoreBand)
|
||||
import Web.View.Hubs.BottleneckDashboard (severityBadge)
|
||||
@@ -26,68 +27,25 @@ instance View OperationalReviewBoardView where
|
||||
<!-- Panel 1: Hub health matrix -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">Hub Health Matrix</h2>
|
||||
{if null hubs
|
||||
then [hsx|<p class="text-sm text-gray-400">No hubs registered.</p>|]
|
||||
else [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Hub</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Health</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Snapshot</th>
|
||||
<th class="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{forEach hubs renderHubRow}
|
||||
</tbody>
|
||||
</table>
|
||||
|]}
|
||||
{renderHubHealthTable hubs}
|
||||
</div>
|
||||
|
||||
<!-- Panel 2: Top friction widgets -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">Top Friction Widgets</h2>
|
||||
{if null topFrictionScores
|
||||
then [hsx|<p class="text-sm text-gray-400">No friction scores computed yet.</p>|]
|
||||
else [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Widget</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Score</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{forEach (zip topFrictionScores topWidgets) renderFrictionRow}
|
||||
</tbody>
|
||||
</table>
|
||||
|]}
|
||||
{renderFrictionTable topFrictionScores topWidgets}
|
||||
</div>
|
||||
|
||||
<!-- Panel 3: Active bottlenecks by stage -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5 mb-4">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">Active Bottlenecks by Stage</h2>
|
||||
{if null bottlenecks
|
||||
then [hsx|<p class="text-sm text-gray-400">No active bottlenecks.</p>|]
|
||||
else [hsx|
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
{forEach stages renderBottleneckStage}
|
||||
</div>
|
||||
|]}
|
||||
{renderBottlenecksPanel bottlenecks}
|
||||
</div>
|
||||
|
||||
<!-- Panel 4: Open cross-hub propagations -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-5">
|
||||
<h2 class="text-sm font-semibold text-gray-700 mb-3">Open Cross-Hub Propagations</h2>
|
||||
{if null openPropagations
|
||||
then [hsx|<p class="text-sm text-gray-400">No open propagation events.</p>|]
|
||||
else [hsx|
|
||||
<div class="space-y-2">
|
||||
{forEach openPropagations renderPropagationRow}
|
||||
</div>
|
||||
|]}
|
||||
{renderPropagationsPanel openPropagations}
|
||||
</div>
|
||||
|]
|
||||
where
|
||||
@@ -108,23 +66,17 @@ instance View OperationalReviewBoardView where
|
||||
in [hsx|
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2">
|
||||
<a href={ShowHubAction { hubId = h.id }}
|
||||
<a href={ShowHubAction (h.id)}
|
||||
class="text-indigo-600 hover:underline">{h.name}</a>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
{case mSnap of
|
||||
Nothing -> [hsx|<span class="text-xs text-gray-400">–</span>|]
|
||||
Just s -> [hsx|
|
||||
<span class={"px-2 py-0.5 rounded text-xs font-semibold " <> healthScoreBadge s.healthScore}>
|
||||
{show s.healthScore}
|
||||
</span>
|
||||
|]}
|
||||
{renderHealthScore mSnap}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-xs text-gray-400">
|
||||
{maybe "never" (\s -> show s.computedAt) mSnap}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-right">
|
||||
<a href={HubHealthHistoryAction { hubId = h.id }}
|
||||
<a href={HubHealthHistoryAction (h.id)}
|
||||
class="text-xs text-indigo-600 hover:underline">History</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -134,7 +86,7 @@ instance View OperationalReviewBoardView where
|
||||
renderFrictionRow (fs, w) = [hsx|
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2">
|
||||
<a href={ShowWidgetAction { widgetId = w.id }}
|
||||
<a href={ShowWidgetAction (w.id)}
|
||||
class="text-indigo-600 hover:underline">{w.name}</a>
|
||||
</td>
|
||||
<td class="px-3 py-2">
|
||||
@@ -170,10 +122,69 @@ instance View OperationalReviewBoardView where
|
||||
<p class="text-xs text-gray-400 mt-0.5">{show p.detectedAt}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<a href={AcknowledgePropagationAction { crossHubPropagationId = p.id }}
|
||||
<a href={AcknowledgePropagationAction (p.id)}
|
||||
class="text-xs text-yellow-600 hover:underline whitespace-nowrap">Acknowledge</a>
|
||||
<a href={ResolvePropagationAction { crossHubPropagationId = p.id }}
|
||||
<a href={ResolvePropagationAction (p.id)}
|
||||
class="text-xs text-green-600 hover:underline">Resolve</a>
|
||||
</div>
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderHubHealthTable :: [Hub] -> Html
|
||||
renderHubHealthTable [] = [hsx|<p class="text-sm text-gray-400">No hubs registered.</p>|]
|
||||
renderHubHealthTable hs = [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Hub</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Health</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Snapshot</th>
|
||||
<th class="px-3 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{forEach hs renderHubRow}
|
||||
</tbody>
|
||||
</table>
|
||||
|]
|
||||
|
||||
renderFrictionTable :: [FrictionScore] -> [Widget] -> Html
|
||||
renderFrictionTable [] _ = [hsx|<p class="text-sm text-gray-400">No friction scores computed yet.</p>|]
|
||||
renderFrictionTable scores ws = [hsx|
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Widget</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Score</th>
|
||||
<th class="text-left px-3 py-2 font-medium text-gray-600">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
{forEach (zip scores ws) renderFrictionRow}
|
||||
</tbody>
|
||||
</table>
|
||||
|]
|
||||
|
||||
renderBottlenecksPanel :: [BottleneckRecord] -> Html
|
||||
renderBottlenecksPanel [] = [hsx|<p class="text-sm text-gray-400">No active bottlenecks.</p>|]
|
||||
renderBottlenecksPanel _ = [hsx|
|
||||
<div class="grid grid-cols-4 gap-3">
|
||||
{forEach stages renderBottleneckStage}
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderPropagationsPanel :: [CrossHubPropagation] -> Html
|
||||
renderPropagationsPanel [] = [hsx|<p class="text-sm text-gray-400">No open propagation events.</p>|]
|
||||
renderPropagationsPanel ps = [hsx|
|
||||
<div class="space-y-2">
|
||||
{forEach ps renderPropagationRow}
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderHealthScore :: Maybe HubHealthSnapshot -> Html
|
||||
renderHealthScore Nothing = [hsx|<span class="text-xs text-gray-400">–</span>|]
|
||||
renderHealthScore (Just s) = [hsx|
|
||||
<span class={"px-2 py-0.5 rounded text-xs font-semibold " <> healthScoreBadge s.healthScore}>
|
||||
{show s.healthScore}
|
||||
</span>
|
||||
|]
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
|
||||
data ShowView = ShowView
|
||||
{ hub :: !Hub
|
||||
@@ -33,39 +34,39 @@ instance View ShowView where
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a href={TriageDashboardAction { hubId = hub.id }}
|
||||
<a href={TriageDashboardAction (hub.id)}
|
||||
class="text-sm border border-indigo-300 text-indigo-700 px-3 py-1.5 rounded hover:bg-indigo-50">
|
||||
Triage Dashboard
|
||||
</a>
|
||||
<a href={GovernanceDashboardAction { hubId = hub.id }}
|
||||
<a href={GovernanceDashboardAction (hub.id)}
|
||||
class="text-sm border border-purple-300 text-purple-700 px-3 py-1.5 rounded hover:bg-purple-50">
|
||||
Governance Dashboard
|
||||
</a>
|
||||
<a href={AntifragilityDashboardAction { hubId = hub.id }}
|
||||
<a href={AntifragilityDashboardAction (hub.id)}
|
||||
class="text-sm border border-green-300 text-green-700 px-3 py-1.5 rounded hover:bg-green-50">
|
||||
Antifragility
|
||||
</a>
|
||||
<a href={AgentAuditDashboardAction { hubId = hub.id }}
|
||||
<a href={AgentAuditDashboardAction (hub.id)}
|
||||
class="text-sm border border-purple-300 text-purple-700 px-3 py-1.5 rounded hover:bg-purple-50">
|
||||
Agent Audit
|
||||
</a>
|
||||
<a href={AdapterCompatibilityDashboardAction { hubId = hub.id }}
|
||||
<a href={AdapterCompatibilityDashboardAction (hub.id)}
|
||||
class="text-sm border border-teal-300 text-teal-700 px-3 py-1.5 rounded hover:bg-teal-50">
|
||||
Adapters
|
||||
</a>
|
||||
<a href={FrictionHeatmapAction { hubId = hub.id }}
|
||||
<a href={FrictionHeatmapAction (hub.id)}
|
||||
class="text-sm border border-orange-300 text-orange-700 px-3 py-1.5 rounded hover:bg-orange-50">
|
||||
Friction
|
||||
</a>
|
||||
<a href={BottleneckDashboardAction { hubId = hub.id }}
|
||||
<a href={BottleneckDashboardAction (hub.id)}
|
||||
class="text-sm border border-red-300 text-red-700 px-3 py-1.5 rounded hover:bg-red-50">
|
||||
Bottlenecks
|
||||
</a>
|
||||
<a href={HubHealthHistoryAction { hubId = hub.id }}
|
||||
<a href={HubHealthHistoryAction (hub.id)}
|
||||
class="text-sm border border-green-300 text-green-700 px-3 py-1.5 rounded hover:bg-green-50">
|
||||
Health
|
||||
</a>
|
||||
<a href={EditHubAction { hubId = hub.id }}
|
||||
<a href={EditHubAction (hub.id)}
|
||||
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
|
||||
Edit
|
||||
</a>
|
||||
@@ -146,7 +147,7 @@ renderWidgetRow :: Widget -> Html
|
||||
renderWidgetRow w = [hsx|
|
||||
<tr class="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td class="px-4 py-3">
|
||||
<a href={ShowWidgetAction { widgetId = w.id }}
|
||||
<a href={ShowWidgetAction (w.id)}
|
||||
class="font-medium text-indigo-600 hover:text-indigo-800">
|
||||
{w.name}
|
||||
</a>
|
||||
@@ -202,12 +203,12 @@ renderManifestSection (Just m) _ = [hsx|
|
||||
<div class="flex items-center gap-2">
|
||||
{manifestStatusBadge m.status}
|
||||
<span class="text-sm text-gray-600">v{m.manifestVersion}</span>
|
||||
{forEach (maybeText m.capabilityDescription) (\d -> [hsx|<span class="text-sm text-gray-500">— {d}</span>|])}
|
||||
{maybe mempty renderCapabilityDesc m.capabilityDescription}
|
||||
</div>
|
||||
<a href={ShowHubCapabilityManifestAction { hubCapabilityManifestId = m.id }}
|
||||
<a href={ShowHubCapabilityManifestAction (m.id)}
|
||||
class="text-sm text-indigo-600 hover:text-indigo-800">View manifest →</a>
|
||||
</div>
|
||||
{forEach (maybeText m.contact) (\c -> [hsx|<p class="text-xs text-gray-400">Contact: {c}</p>|])}
|
||||
{maybe mempty renderManifestContactLine m.contact}
|
||||
</div>
|
||||
|]
|
||||
|
||||
@@ -225,3 +226,9 @@ kindBadge _ = [hsx|<span class="px-2 py-0.5 rounded text-xs bg-blue-10
|
||||
maybeText :: Maybe Text -> [Text]
|
||||
maybeText Nothing = []
|
||||
maybeText (Just t) = [t]
|
||||
|
||||
renderCapabilityDesc :: Text -> Html
|
||||
renderCapabilityDesc d = [hsx|<span class="text-sm text-gray-500">— {d}</span>|]
|
||||
|
||||
renderManifestContactLine :: Text -> Html
|
||||
renderManifestContactLine c = [hsx|<p class="text-xs text-gray-400">Contact: {c}</p>|]
|
||||
|
||||
@@ -4,6 +4,7 @@ import Web.Types
|
||||
import Generated.Types
|
||||
import IHP.Prelude
|
||||
import IHP.ViewPrelude
|
||||
import Web.Routes ()
|
||||
|
||||
data TriageDashboardView = TriageDashboardView
|
||||
{ hub :: !Hub
|
||||
@@ -20,7 +21,7 @@ instance View TriageDashboardView where
|
||||
<div class="mb-6 flex items-center gap-2 text-sm text-gray-500">
|
||||
<a href={HubsAction} class="hover:text-gray-700">Hubs</a>
|
||||
<span>/</span>
|
||||
<a href={ShowHubAction { hubId = hub.id }} class="hover:text-gray-700">{hub.name}</a>
|
||||
<a href={ShowHubAction (hub.id)} class="hover:text-gray-700">{hub.name}</a>
|
||||
<span>/</span>
|
||||
<span>Triage Dashboard</span>
|
||||
</div>
|
||||
@@ -46,25 +47,13 @@ instance View TriageDashboardView where
|
||||
<!-- Triage queue -->
|
||||
<section>
|
||||
<h2 class="text-lg font-medium mb-3">Triage Queue (Open)</h2>
|
||||
{if null triageQueue
|
||||
then [hsx|<p class="text-sm text-gray-400">Queue empty.</p>|]
|
||||
else [hsx|
|
||||
<div class="space-y-2">
|
||||
{forEach triageQueue (renderQueueItem widgets)}
|
||||
</div>
|
||||
|]}
|
||||
{renderTriageQueue triageQueue widgets}
|
||||
</section>
|
||||
|
||||
<!-- Recent escalations -->
|
||||
<section>
|
||||
<h2 class="text-lg font-medium mb-3">Recent Escalations</h2>
|
||||
{if null recentEscalations
|
||||
then [hsx|<p class="text-sm text-gray-400">No escalations yet.</p>|]
|
||||
else [hsx|
|
||||
<div class="space-y-2">
|
||||
{forEach recentEscalations (renderEscalationItem widgets)}
|
||||
</div>
|
||||
|]}
|
||||
{renderEscalationsSection recentEscalations widgets}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -85,6 +74,22 @@ renderKpi label status candidates colorClass =
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderTriageQueue :: [RequirementCandidate] -> [Widget] -> Html
|
||||
renderTriageQueue [] _ = [hsx|<p class="text-sm text-gray-400">Queue empty.</p>|]
|
||||
renderTriageQueue items ws = [hsx|
|
||||
<div class="space-y-2">
|
||||
{forEach items (renderQueueItem ws)}
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderEscalationsSection :: [RequirementCandidate] -> [Widget] -> Html
|
||||
renderEscalationsSection [] _ = [hsx|<p class="text-sm text-gray-400">No escalations yet.</p>|]
|
||||
renderEscalationsSection items ws = [hsx|
|
||||
<div class="space-y-2">
|
||||
{forEach items (renderEscalationItem ws)}
|
||||
</div>
|
||||
|]
|
||||
|
||||
renderQueueItem :: [Widget] -> RequirementCandidate -> Html
|
||||
renderQueueItem widgets c =
|
||||
let mWidget = find (\w -> w.id == c.sourceWidgetId) widgets
|
||||
@@ -92,7 +97,7 @@ renderQueueItem widgets c =
|
||||
in [hsx|
|
||||
<div class="bg-white rounded border border-gray-200 px-4 py-3">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<a href={ShowRequirementCandidateAction { requirementCandidateId = c.id }}
|
||||
<a href={ShowRequirementCandidateAction (c.id)}
|
||||
class="text-sm font-medium text-indigo-600 hover:text-indigo-800 leading-snug">
|
||||
{c.title}
|
||||
</a>
|
||||
@@ -115,7 +120,7 @@ renderEscalationItem widgets c =
|
||||
<span class={statusClass c.status <> " text-xs px-2 py-0.5 rounded"}>{c.status}</span>
|
||||
<span class="text-xs text-gray-500">{maybe "—" (.name) mWidget}</span>
|
||||
</div>
|
||||
<a href={ShowRequirementCandidateAction { requirementCandidateId = c.id }}
|
||||
<a href={ShowRequirementCandidateAction (c.id)}
|
||||
class="text-sm text-indigo-600 hover:text-indigo-800">{c.title}</a>
|
||||
</div>
|
||||
|]
|
||||
|
||||
Reference in New Issue
Block a user