Skip to content

Commit d39bfbd

Browse files
committed
UI: ehance the user interface for delivery-targets and add the page
1 parent 744ed1d commit d39bfbd

10 files changed

Lines changed: 2068 additions & 936 deletions

File tree

web/src/app/dashboard/dashboard-client.tsx

Lines changed: 1360 additions & 434 deletions
Large diffs are not rendered by default.

web/src/app/dashboard/page.tsx

Lines changed: 127 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,162 @@
11
export const dynamic = "force-dynamic"
2+
23
import { redirect } from "next/navigation"
34
import { promQuery, getScalar } from "@/lib/prometheus"
45
import { getCurrentUser } from "@/lib/auth"
56
import DashboardClient from "@/app/dashboard/dashboard-client"
67

8+
/* ---------------- Helpers ---------------- */
9+
10+
function parseTimeSeries(data: unknown): [number, string][] {
11+
if (!Array.isArray(data)) return []
12+
13+
return data
14+
.map((item) => {
15+
if (
16+
Array.isArray(item) &&
17+
item.length === 2 &&
18+
typeof item[0] === "number"
19+
) {
20+
return [item[0], String(item[1])]
21+
}
22+
return null
23+
})
24+
.filter(Boolean) as [number, string][]
25+
}
26+
27+
/* ---------------- API ---------------- */
28+
729
async function getRecentEvents() {
8-
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/events?limit=5`, {
9-
credentials: "include",
10-
cache: "no-store",
11-
})
12-
13-
if (!res.ok) return []
14-
const data = await res.json()
15-
return data.items || []
30+
try {
31+
const res = await fetch(
32+
`${process.env.NEXT_PUBLIC_API_URL}/events?limit=5`,
33+
{ credentials: "include", cache: "no-store" }
34+
)
35+
36+
if (!res.ok) return []
37+
38+
const data = await res.json()
39+
return data.items || []
40+
} catch {
41+
return []
42+
}
1643
}
1744

1845
async function getEndpoints() {
19-
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/routes`, {
20-
credentials: "include",
21-
cache: "no-store",
22-
})
46+
try {
47+
const res = await fetch(
48+
`${process.env.NEXT_PUBLIC_API_URL}/routes`,
49+
{ credentials: "include", cache: "no-store" }
50+
)
51+
52+
if (!res.ok) return []
53+
54+
const data = await res.json()
55+
return data.items || data || []
56+
} catch {
57+
return []
58+
}
59+
}
2360

24-
if (!res.ok) return []
25-
return res.json()
61+
async function getDLQCount() {
62+
try {
63+
const res = await fetch(
64+
`${process.env.NEXT_PUBLIC_API_URL}/events?status=dlq`,
65+
{ cache: "no-store" }
66+
)
67+
68+
if (!res.ok) return 0
69+
70+
const data = await res.json()
71+
return data.items?.length || 0
72+
} catch {
73+
return 0
74+
}
2675
}
2776

77+
/* ---------------- PAGE ---------------- */
78+
2879
export default async function Dashboard() {
2980
const user = await getCurrentUser()
30-
3181
if (!user) redirect("/login")
3282

33-
const [totalEvents, delivered, failed, retries, events, endpoints] =
34-
await Promise.all([
83+
// ✅ Proper typing (FIXED)
84+
let totalEvents: unknown = []
85+
let delivered: unknown = []
86+
let failed: unknown = []
87+
let retries: unknown = []
88+
let successSeries: unknown = []
89+
let failureSeries: unknown = []
90+
let events: unknown = []
91+
let endpoints: unknown = []
92+
let dlqCount = 0
93+
94+
try {
95+
[
96+
totalEvents,
97+
delivered,
98+
failed,
99+
retries,
100+
successSeries,
101+
failureSeries,
102+
events,
103+
endpoints,
104+
dlqCount,
105+
] = await Promise.all([
35106
promQuery("sum(hooktrace_webhooks_received_total)"),
36107
promQuery("sum(hooktrace_events_delivered_total)"),
37108
promQuery("sum(hooktrace_events_failed_total)"),
38109
promQuery("sum(hooktrace_events_retried_total)"),
110+
111+
promQuery("rate(hooktrace_events_delivered_total[1m])"),
112+
promQuery("rate(hooktrace_events_failed_total[1m])"),
113+
39114
getRecentEvents(),
40115
getEndpoints(),
116+
getDLQCount(),
41117
])
118+
} catch (err) {
119+
console.error("Dashboard error:", err)
120+
}
121+
122+
/* ---------------- Safe Parsing ---------------- */
42123

43124
const stats = [
44-
{ label: "Total Events", value: getScalar(totalEvents as unknown[]) },
45-
{ label: "Delivered", value: getScalar(delivered as unknown[]) },
46-
{ label: "Failed", value: getScalar(failed as unknown[]) },
47-
{ label: "Retries", value: getScalar(retries as unknown[]) },
125+
{
126+
label: "Total Events",
127+
value: Array.isArray(totalEvents) ? getScalar(totalEvents) || 0 : 0,
128+
},
129+
{
130+
label: "Delivered",
131+
value: Array.isArray(delivered) ? getScalar(delivered) || 0 : 0,
132+
},
133+
{
134+
label: "Failed",
135+
value: Array.isArray(failed) ? getScalar(failed) || 0 : 0,
136+
},
137+
{
138+
label: "Retries",
139+
value: Array.isArray(retries) ? getScalar(retries) || 0 : 0,
140+
},
48141
]
49142

143+
const parsedSuccess = parseTimeSeries(successSeries)
144+
const parsedFailure = parseTimeSeries(failureSeries)
145+
146+
const safeEvents = Array.isArray(events) ? events : []
147+
const safeEndpoints = Array.isArray(endpoints) ? endpoints : []
148+
149+
/* ---------------- Render ---------------- */
150+
50151
return (
51152
<DashboardClient
52153
stats={stats}
53154
user={user}
54-
recentEvents={events}
55-
endpoints={endpoints}
155+
recentEvents={safeEvents}
156+
endpoints={safeEndpoints}
157+
successSeries={parsedSuccess}
158+
failureSeries={parsedFailure}
159+
dlqCount={dlqCount}
56160
/>
57161
)
58162
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"use client"
2+
3+
import { motion } from "framer-motion"
4+
import {
5+
CheckCircle2,
6+
AlertTriangle,
7+
Zap,
8+
Play,
9+
} from "lucide-react"
10+
11+
import { ThemeToggle } from "@/components/theme-toggle"
12+
import { UserNav } from "@/components/user-nav"
13+
import { useState } from "react"
14+
import type { DeliveryTarget, TargetStats } from "@/types/delivery-target"
15+
16+
17+
18+
19+
export default function DeliveryTargetDetailClient({
20+
target,
21+
stats,
22+
user,
23+
}: {
24+
target: DeliveryTarget
25+
stats: TargetStats
26+
user: { email: string; avatar_url?: string }
27+
}){
28+
const successRate = stats?.successRate || 0
29+
const isHealthy = successRate >= 95
30+
31+
const [loading, setLoading] = useState(false)
32+
33+
async function testTarget() {
34+
setLoading(true)
35+
36+
try {
37+
await fetch(
38+
`${process.env.NEXT_PUBLIC_API_URL}/delivery-targets/${target.id}/test`,
39+
{ method: "POST", credentials: "include" }
40+
)
41+
} finally {
42+
setLoading(false)
43+
location.reload()
44+
}
45+
}
46+
47+
return (
48+
<div className="min-h-screen bg-background">
49+
<div className="mx-auto max-w-7xl px-6 py-8 space-y-6">
50+
51+
{/* Header */}
52+
<div className="flex justify-between items-center">
53+
<div>
54+
<h1 className="text-3xl font-bold">{target.name}</h1>
55+
<p className="text-sm text-muted-foreground">
56+
{target.type}
57+
</p>
58+
</div>
59+
60+
<div className="flex items-center gap-3">
61+
<ThemeToggle />
62+
<UserNav user={user} />
63+
</div>
64+
</div>
65+
66+
{/* Status */}
67+
<div className={`p-4 rounded-xl border flex justify-between ${
68+
isHealthy ? "bg-emerald-50" : "bg-amber-50"
69+
}`}>
70+
<div className="flex items-center gap-3">
71+
72+
{isHealthy ? (
73+
<CheckCircle2 className="text-emerald-600" />
74+
) : (
75+
<AlertTriangle className="text-amber-600" />
76+
)}
77+
78+
<div>
79+
<p className="font-semibold text-sm">
80+
{isHealthy ? "Healthy" : "Issues detected"}
81+
</p>
82+
83+
<p className="text-xs text-muted-foreground">
84+
Success rate: {successRate}%
85+
</p>
86+
87+
<p className="text-xs text-muted-foreground">
88+
Last used: {stats?.lastUsed ? new Date(stats.lastUsed).toLocaleString() : "Never"}
89+
</p>
90+
</div>
91+
</div>
92+
93+
<button
94+
onClick={testTarget}
95+
disabled={loading}
96+
className="flex items-center gap-2 px-3 py-1.5 border rounded-md"
97+
>
98+
<Play className="w-4 h-4" />
99+
{loading ? "Testing..." : "Test"}
100+
</button>
101+
</div>
102+
103+
{/* Metrics */}
104+
<div className="grid grid-cols-3 gap-4">
105+
106+
<Card label="Success" value={stats.successCount} />
107+
<Card label="Errors" value={stats.errorCount} />
108+
<Card label="Rate" value={`${stats.successRate}%`} />
109+
110+
</div>
111+
112+
{/* Config */}
113+
<div className="rounded-xl border bg-card p-6">
114+
<h2 className="font-semibold mb-4">Configuration</h2>
115+
116+
<pre className="text-xs bg-muted p-4 rounded-lg overflow-auto">
117+
{JSON.stringify(target.config, null, 2)}
118+
</pre>
119+
</div>
120+
121+
122+
{/* Providers */}
123+
<div className="rounded-xl border bg-card p-6">
124+
<h2 className="font-semibold mb-4">Providers</h2>
125+
126+
<div className="flex gap-2 flex-wrap">
127+
{target.providers?.length ? (
128+
target.providers.map((p: string) => (
129+
<span
130+
key={p}
131+
className="text-xs px-2 py-1 rounded bg-muted"
132+
>
133+
{p}
134+
</span>
135+
))
136+
) : (
137+
<span className="text-xs text-muted-foreground">
138+
No provider restrictions
139+
</span>
140+
)}
141+
</div>
142+
</div>
143+
144+
{/* Insights */}
145+
<div className="rounded-xl border bg-card p-6">
146+
<h2 className="font-semibold mb-4">Observability Insight</h2>
147+
148+
<p className="text-sm text-muted-foreground">
149+
{successRate >= 98
150+
? "Delivery pipeline is highly stable with minimal failure rate."
151+
: successRate >= 90
152+
? "Minor delivery issues detected. Monitor retry behavior."
153+
: "Significant failures detected. Check endpoint health, retries, or configuration."}
154+
</p>
155+
</div>
156+
157+
</div>
158+
</div>
159+
)
160+
}
161+
162+
/* ---------------- UI ---------------- */
163+
164+
function Card({
165+
label,
166+
value,
167+
}: {
168+
label: string
169+
value: string | number
170+
}) {
171+
return (
172+
<motion.div
173+
className="rounded-xl border bg-card p-5"
174+
initial={{ opacity: 0, y: 10 }}
175+
animate={{ opacity: 1, y: 0 }}
176+
>
177+
<p className="text-sm text-muted-foreground">{label}</p>
178+
<p className="text-xl font-bold">{value}</p>
179+
</motion.div>
180+
)
181+
}

0 commit comments

Comments
 (0)