@odvi/create-dtt-framework 0.1.2 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/create.d.ts.map +1 -1
- package/dist/commands/create.js +16 -13
- package/dist/commands/create.js.map +1 -1
- package/dist/utils/template.d.ts.map +1 -1
- package/dist/utils/template.js +5 -0
- package/dist/utils/template.js.map +1 -1
- package/package.json +3 -2
- package/template/.env.example +103 -0
- package/template/components.json +22 -0
- package/template/docs/framework/01-overview.md +289 -0
- package/template/docs/framework/02-techstack.md +503 -0
- package/template/docs/framework/api-layer.md +681 -0
- package/template/docs/framework/clerk-authentication.md +649 -0
- package/template/docs/framework/cli-installation.md +564 -0
- package/template/docs/framework/deployment/ci-cd.md +907 -0
- package/template/docs/framework/deployment/digitalocean.md +991 -0
- package/template/docs/framework/deployment/domain-setup.md +972 -0
- package/template/docs/framework/deployment/environment-variables.md +863 -0
- package/template/docs/framework/deployment/monitoring.md +927 -0
- package/template/docs/framework/deployment/production-checklist.md +649 -0
- package/template/docs/framework/deployment/vercel.md +791 -0
- package/template/docs/framework/environment-variables.md +658 -0
- package/template/docs/framework/health-check-system.md +582 -0
- package/template/docs/framework/implementation.md +559 -0
- package/template/docs/framework/snowflake-integration.md +591 -0
- package/template/docs/framework/state-management.md +615 -0
- package/template/docs/framework/supabase-integration.md +581 -0
- package/template/docs/framework/testing-guide.md +544 -0
- package/template/docs/framework/what-did-i-miss.md +526 -0
- package/template/drizzle.config.ts +12 -0
- package/template/next.config.js +21 -0
- package/template/postcss.config.js +5 -0
- package/template/prettier.config.js +4 -0
- package/template/public/favicon.ico +0 -0
- package/template/src/app/(auth)/layout.tsx +4 -0
- package/template/src/app/(auth)/sign-in/[[...sign-in]]/page.tsx +10 -0
- package/template/src/app/(auth)/sign-up/[[...sign-up]]/page.tsx +10 -0
- package/template/src/app/(dashboard)/dashboard/page.tsx +8 -0
- package/template/src/app/(dashboard)/health/page.tsx +16 -0
- package/template/src/app/(dashboard)/layout.tsx +17 -0
- package/template/src/app/api/[[...route]]/route.ts +11 -0
- package/template/src/app/api/debug-files/route.ts +33 -0
- package/template/src/app/api/webhooks/clerk/route.ts +112 -0
- package/template/src/app/layout.tsx +28 -0
- package/template/src/app/page.tsx +12 -0
- package/template/src/app/providers.tsx +20 -0
- package/template/src/components/layouts/navbar.tsx +14 -0
- package/template/src/components/shared/loading-spinner.tsx +6 -0
- package/template/src/components/ui/badge.tsx +46 -0
- package/template/src/components/ui/button.tsx +62 -0
- package/template/src/components/ui/card.tsx +92 -0
- package/template/src/components/ui/collapsible.tsx +33 -0
- package/template/src/components/ui/scroll-area.tsx +58 -0
- package/template/src/components/ui/sheet.tsx +139 -0
- package/template/src/config/__tests__/env.test.ts +166 -0
- package/template/src/config/__tests__/site.test.ts +46 -0
- package/template/src/config/env.ts +36 -0
- package/template/src/config/site.ts +10 -0
- package/template/src/env.js +44 -0
- package/template/src/features/__tests__/health-check-config.test.ts +142 -0
- package/template/src/features/__tests__/health-check-types.test.ts +201 -0
- package/template/src/features/documentation/components/doc-sidebar.tsx +109 -0
- package/template/src/features/documentation/components/doc-viewer.tsx +70 -0
- package/template/src/features/documentation/index.tsx +92 -0
- package/template/src/features/documentation/utils/doc-loader.ts +177 -0
- package/template/src/features/health-check/components/health-dashboard.tsx +363 -0
- package/template/src/features/health-check/config.ts +72 -0
- package/template/src/features/health-check/index.ts +4 -0
- package/template/src/features/health-check/stores/health-store.ts +14 -0
- package/template/src/features/health-check/types.ts +18 -0
- package/template/src/hooks/__tests__/use-debounce.test.tsx +28 -0
- package/template/src/hooks/queries/use-health-checks.ts +16 -0
- package/template/src/hooks/utils/use-debounce.ts +20 -0
- package/template/src/lib/__tests__/utils.test.ts +52 -0
- package/template/src/lib/__tests__/validators.test.ts +114 -0
- package/template/src/lib/nextbank/client.ts +37 -0
- package/template/src/lib/snowflake/client.ts +53 -0
- package/template/src/lib/supabase/admin.ts +7 -0
- package/template/src/lib/supabase/client.ts +7 -0
- package/template/src/lib/supabase/server.ts +23 -0
- package/template/src/lib/utils.ts +6 -0
- package/template/src/lib/validators.ts +9 -0
- package/template/src/middleware.ts +22 -0
- package/template/src/server/api/index.ts +22 -0
- package/template/src/server/api/middleware/auth.ts +19 -0
- package/template/src/server/api/middleware/logger.ts +4 -0
- package/template/src/server/api/routes/health/clerk.ts +214 -0
- package/template/src/server/api/routes/health/database.ts +117 -0
- package/template/src/server/api/routes/health/edge-functions.ts +75 -0
- package/template/src/server/api/routes/health/framework.ts +45 -0
- package/template/src/server/api/routes/health/index.ts +102 -0
- package/template/src/server/api/routes/health/nextbank.ts +67 -0
- package/template/src/server/api/routes/health/snowflake.ts +83 -0
- package/template/src/server/api/routes/health/storage.ts +163 -0
- package/template/src/server/api/routes/users.ts +95 -0
- package/template/src/server/db/index.ts +17 -0
- package/template/src/server/db/queries/users.ts +8 -0
- package/template/src/server/db/schema/__tests__/health-checks.test.ts +31 -0
- package/template/src/server/db/schema/__tests__/users.test.ts +46 -0
- package/template/src/server/db/schema/health-checks.ts +11 -0
- package/template/src/server/db/schema/index.ts +2 -0
- package/template/src/server/db/schema/users.ts +16 -0
- package/template/src/server/db/schema.ts +26 -0
- package/template/src/stores/__tests__/ui-store.test.ts +87 -0
- package/template/src/stores/ui-store.ts +14 -0
- package/template/src/styles/globals.css +129 -0
- package/template/src/test/mocks/clerk.ts +35 -0
- package/template/src/test/mocks/snowflake.ts +28 -0
- package/template/src/test/mocks/supabase.ts +37 -0
- package/template/src/test/setup.ts +69 -0
- package/template/src/test/utils/test-helpers.ts +158 -0
- package/template/src/types/index.ts +14 -0
- package/template/tsconfig.json +43 -0
- package/template/vitest.config.ts +44 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { SERVICES } from '@/features/health-check/config'
|
|
4
|
+
import { LoadingSpinner } from '@/components/shared/loading-spinner'
|
|
5
|
+
import { Button } from '@/components/ui/button'
|
|
6
|
+
import { Card } from '@/components/ui/card'
|
|
7
|
+
import { Badge } from '@/components/ui/badge'
|
|
8
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
|
|
9
|
+
import { CheckCircle, XCircle, AlertCircle, Clock, Shield, Play, RefreshCw, ChevronDown } from 'lucide-react'
|
|
10
|
+
import { useState } from 'react'
|
|
11
|
+
import type { HealthStatus } from '@/features/health-check/types'
|
|
12
|
+
import { useHealthStore } from '../stores/health-store'
|
|
13
|
+
import { useQueryClient } from '@tanstack/react-query'
|
|
14
|
+
|
|
15
|
+
const statusIcons: Record<HealthStatus, React.ReactNode> = {
|
|
16
|
+
healthy: <CheckCircle className="h-5 w-5 text-green-500" />,
|
|
17
|
+
unhealthy: <XCircle className="h-5 w-5 text-red-500" />,
|
|
18
|
+
error: <XCircle className="h-5 w-5 text-red-500" />,
|
|
19
|
+
pending: <Clock className="h-5 w-5 text-yellow-500" />,
|
|
20
|
+
unconfigured: <Shield className="h-5 w-5 text-gray-400" />,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const statusColors: Record<HealthStatus, string> = {
|
|
24
|
+
healthy: 'bg-green-100 text-green-800 border-green-200',
|
|
25
|
+
unhealthy: 'bg-red-100 text-red-800 border-red-200',
|
|
26
|
+
error: 'bg-red-100 text-red-800 border-red-200',
|
|
27
|
+
pending: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
|
28
|
+
unconfigured: 'bg-gray-100 text-gray-800 border-gray-200',
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface IndividualCheckResult {
|
|
32
|
+
name: string
|
|
33
|
+
status: HealthStatus
|
|
34
|
+
responseTimeMs?: number
|
|
35
|
+
error?: string
|
|
36
|
+
httpStatus?: number
|
|
37
|
+
timestamp?: string
|
|
38
|
+
data?: any
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function HealthDashboard() {
|
|
42
|
+
const [individualChecks, setIndividualChecks] = useState<Record<string, IndividualCheckResult>>({})
|
|
43
|
+
const [loadingChecks, setLoadingChecks] = useState<Set<string>>(new Set())
|
|
44
|
+
const healthStore = useHealthStore()
|
|
45
|
+
const queryClient = useQueryClient()
|
|
46
|
+
|
|
47
|
+
const totalHealthChecks = SERVICES.reduce((total, service) => total + service.checks.length, 0)
|
|
48
|
+
|
|
49
|
+
const runIndividualCheck = async (serviceName: string, checkName: string, endpoint: string) => {
|
|
50
|
+
const checkKey = `${serviceName}-${checkName}`
|
|
51
|
+
setLoadingChecks((prev) => new Set(prev).add(checkKey))
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
let result: { status?: string; responseTimeMs?: number; error?: string; data?: any }
|
|
55
|
+
const start = performance.now()
|
|
56
|
+
|
|
57
|
+
if (endpoint.startsWith('client:')) {
|
|
58
|
+
// Handle client-side checks
|
|
59
|
+
await new Promise((resolve) => setTimeout(resolve, 500)) // Simulate async work
|
|
60
|
+
|
|
61
|
+
if (endpoint === 'client:zustand') {
|
|
62
|
+
healthStore.increment()
|
|
63
|
+
const currentCount = useHealthStore.getState().counter
|
|
64
|
+
result = {
|
|
65
|
+
status: 'healthy',
|
|
66
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
67
|
+
data: {
|
|
68
|
+
message: 'Zustand store updated successfully',
|
|
69
|
+
counter: currentCount
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} else if (endpoint === 'client:query') {
|
|
73
|
+
// Simulate a query check
|
|
74
|
+
const queryState = queryClient.getQueryCache().getAll()
|
|
75
|
+
result = {
|
|
76
|
+
status: 'healthy',
|
|
77
|
+
responseTimeMs: Math.round(performance.now() - start),
|
|
78
|
+
data: {
|
|
79
|
+
message: 'Query Client is active',
|
|
80
|
+
queryCount: queryState.length,
|
|
81
|
+
isFetching: queryClient.isFetching()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} else {
|
|
85
|
+
throw new Error(`Unknown client endpoint: ${endpoint}`)
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
// Handle server-side checks
|
|
89
|
+
const response = await fetch(`/api/health${endpoint}`)
|
|
90
|
+
// Check content type to ensure JSON
|
|
91
|
+
const contentType = response.headers.get('content-type')
|
|
92
|
+
if (!contentType || !contentType.includes('application/json')) {
|
|
93
|
+
const text = await response.text()
|
|
94
|
+
throw new Error(`Invalid response from server: ${text.substring(0, 100)}...`)
|
|
95
|
+
}
|
|
96
|
+
result = await response.json()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setIndividualChecks((prev) => ({
|
|
100
|
+
...prev,
|
|
101
|
+
[checkKey]: {
|
|
102
|
+
name: checkName,
|
|
103
|
+
status: (result.status ?? ('healthy')) as HealthStatus,
|
|
104
|
+
responseTimeMs: result.responseTimeMs,
|
|
105
|
+
error: result.error,
|
|
106
|
+
httpStatus: endpoint.startsWith('client:') ? 200 : undefined,
|
|
107
|
+
timestamp: new Date().toISOString(),
|
|
108
|
+
data: result.data,
|
|
109
|
+
},
|
|
110
|
+
}))
|
|
111
|
+
} catch (err) {
|
|
112
|
+
setIndividualChecks((prev) => ({
|
|
113
|
+
...prev,
|
|
114
|
+
[checkKey]: {
|
|
115
|
+
name: checkName,
|
|
116
|
+
status: 'error',
|
|
117
|
+
error: err instanceof Error ? err.message : 'Check failed',
|
|
118
|
+
timestamp: new Date().toISOString(),
|
|
119
|
+
// Include responseTimeMs even on error for better UX
|
|
120
|
+
responseTimeMs: 0,
|
|
121
|
+
},
|
|
122
|
+
}))
|
|
123
|
+
} finally {
|
|
124
|
+
setLoadingChecks((prev) => {
|
|
125
|
+
const next = new Set(prev)
|
|
126
|
+
next.delete(checkKey)
|
|
127
|
+
return next
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const runServiceChecks = async (serviceName: string) => {
|
|
133
|
+
const service = SERVICES.find((s) => s.name === serviceName)
|
|
134
|
+
if (!service) return
|
|
135
|
+
|
|
136
|
+
for (const check of service.checks) {
|
|
137
|
+
await runIndividualCheck(serviceName, check.name, check.endpoint)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const runAllChecks = async () => {
|
|
142
|
+
// Run all checks in parallel
|
|
143
|
+
const promises = SERVICES.flatMap((service) =>
|
|
144
|
+
service.checks.map((check) => runIndividualCheck(service.name, check.name, check.endpoint))
|
|
145
|
+
)
|
|
146
|
+
await Promise.all(promises)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Calculate derived status from individual checks
|
|
150
|
+
const getServiceStatus = (serviceName: string): HealthStatus => {
|
|
151
|
+
const service = SERVICES.find((s) => s.name === serviceName)
|
|
152
|
+
if (!service) return 'pending'
|
|
153
|
+
|
|
154
|
+
const checks = service.checks
|
|
155
|
+
.map((check) => {
|
|
156
|
+
const checkKey = `${serviceName}-${check.name}`
|
|
157
|
+
return individualChecks[checkKey]
|
|
158
|
+
})
|
|
159
|
+
.filter((c): c is IndividualCheckResult => !!c)
|
|
160
|
+
|
|
161
|
+
if (checks.length === 0) return 'pending'
|
|
162
|
+
|
|
163
|
+
if (checks.some((c) => c.status === 'error' || (c.httpStatus && c.httpStatus >= 400))) return 'error'
|
|
164
|
+
if (checks.some((c) => c.status === 'unconfigured')) return 'unconfigured'
|
|
165
|
+
if (checks.length < service.checks.length) return 'pending'
|
|
166
|
+
|
|
167
|
+
return 'healthy'
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const overallStatus = (() => {
|
|
171
|
+
const statuses = SERVICES.map((s) => getServiceStatus(s.name))
|
|
172
|
+
if (statuses.some((s) => s === 'error')) return 'error'
|
|
173
|
+
if (statuses.some((s) => s === 'pending')) return 'pending'
|
|
174
|
+
if (statuses.some((s) => s === 'unconfigured')) return 'unconfigured'
|
|
175
|
+
return 'healthy'
|
|
176
|
+
})()
|
|
177
|
+
|
|
178
|
+
const lastUpdated = Object.values(individualChecks)
|
|
179
|
+
.map((c) => c.timestamp)
|
|
180
|
+
.filter(Boolean)
|
|
181
|
+
.sort()
|
|
182
|
+
.pop()
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="space-y-6">
|
|
186
|
+
{/* Overall Status */}
|
|
187
|
+
<Card className="p-6">
|
|
188
|
+
<Collapsible defaultOpen>
|
|
189
|
+
<CollapsibleTrigger className="w-full group">
|
|
190
|
+
<div className="flex items-center justify-between">
|
|
191
|
+
<div className="flex items-center gap-4">
|
|
192
|
+
<div className="text-3xl">{statusIcons[overallStatus]}</div>
|
|
193
|
+
<div className="text-left">
|
|
194
|
+
<h2 className="text-xl font-semibold">Overall System Status</h2>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
<div className="flex items-center gap-4">
|
|
198
|
+
<Badge className={statusColors[overallStatus]} variant="outline">
|
|
199
|
+
{overallStatus.toUpperCase()}
|
|
200
|
+
</Badge>
|
|
201
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
</CollapsibleTrigger>
|
|
205
|
+
<CollapsibleContent>
|
|
206
|
+
<div className="mt-4 flex gap-2">
|
|
207
|
+
<Button onClick={runAllChecks} size="sm" className="gap-2">
|
|
208
|
+
<RefreshCw className="h-4 w-4" />
|
|
209
|
+
Run All Checks ({totalHealthChecks})
|
|
210
|
+
</Button>
|
|
211
|
+
{lastUpdated && (
|
|
212
|
+
<p className="text-xs text-muted-foreground self-center ml-auto">
|
|
213
|
+
Last updated: {new Date(lastUpdated).toLocaleString()}
|
|
214
|
+
</p>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
</CollapsibleContent>
|
|
218
|
+
</Collapsible>
|
|
219
|
+
</Card>
|
|
220
|
+
|
|
221
|
+
{/* Service Cards */}
|
|
222
|
+
<div className="space-y-4">
|
|
223
|
+
{SERVICES.map((service) => {
|
|
224
|
+
const serviceStatus = getServiceStatus(service.name)
|
|
225
|
+
const isServiceLoading = Array.from(loadingChecks).some((key) => key.startsWith(`${service.name}-`))
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<Card key={service.name} className="p-4">
|
|
229
|
+
<Collapsible defaultOpen>
|
|
230
|
+
<CollapsibleTrigger className="w-full group">
|
|
231
|
+
<div className="flex items-center justify-between mb-4">
|
|
232
|
+
<div className="flex items-center gap-3">
|
|
233
|
+
<div className="text-xl">{statusIcons[serviceStatus]}</div>
|
|
234
|
+
<div className="text-left">
|
|
235
|
+
<h3 className="font-semibold">{service.name}</h3>
|
|
236
|
+
<p className="text-sm text-muted-foreground">
|
|
237
|
+
{service.checks.length} check{(service.checks.length as number) !== 1 ? 's' : ''}
|
|
238
|
+
</p>
|
|
239
|
+
</div>
|
|
240
|
+
<Button
|
|
241
|
+
size="sm"
|
|
242
|
+
variant="outline"
|
|
243
|
+
className="gap-2 ml-2"
|
|
244
|
+
disabled={isServiceLoading}
|
|
245
|
+
onClick={(e) => {
|
|
246
|
+
e.stopPropagation()
|
|
247
|
+
runServiceChecks(service.name)
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
{isServiceLoading ? (
|
|
251
|
+
<LoadingSpinner className="h-3 w-3" />
|
|
252
|
+
) : (
|
|
253
|
+
<Play className="h-3 w-3" />
|
|
254
|
+
)}
|
|
255
|
+
Run Checks
|
|
256
|
+
</Button>
|
|
257
|
+
</div>
|
|
258
|
+
<div className="flex items-center gap-4">
|
|
259
|
+
<Badge className={statusColors[serviceStatus]} variant="outline">
|
|
260
|
+
{serviceStatus.toUpperCase()}
|
|
261
|
+
</Badge>
|
|
262
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</CollapsibleTrigger>
|
|
266
|
+
|
|
267
|
+
<CollapsibleContent>
|
|
268
|
+
<div className="space-y-3">
|
|
269
|
+
{service.checks.map((check) => {
|
|
270
|
+
const checkKey = `${service.name}-${check.name}`
|
|
271
|
+
const individualResult = individualChecks[checkKey]
|
|
272
|
+
const isLoadingCheck = loadingChecks.has(checkKey)
|
|
273
|
+
const checkStatus: HealthStatus = individualResult?.status ?? 'pending'
|
|
274
|
+
|
|
275
|
+
return (
|
|
276
|
+
<div key={check.name} className="border rounded-lg p-3 bg-muted/30">
|
|
277
|
+
<Collapsible defaultOpen={!!individualResult}>
|
|
278
|
+
<div className="flex items-center justify-between mb-2">
|
|
279
|
+
<CollapsibleTrigger className="group flex items-center gap-2 hover:opacity-80 transition-opacity">
|
|
280
|
+
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
|
281
|
+
<div className="flex items-center gap-2 text-left">
|
|
282
|
+
<div className="text-lg">{statusIcons[checkStatus]}</div>
|
|
283
|
+
<div>
|
|
284
|
+
<p className="font-medium text-sm">{check.name}</p>
|
|
285
|
+
<p className="text-xs text-muted-foreground">{check.endpoint}</p>
|
|
286
|
+
</div>
|
|
287
|
+
</div>
|
|
288
|
+
</CollapsibleTrigger>
|
|
289
|
+
<Button
|
|
290
|
+
size="sm"
|
|
291
|
+
variant="outline"
|
|
292
|
+
onClick={() => runIndividualCheck(service.name, check.name, check.endpoint)}
|
|
293
|
+
disabled={isLoadingCheck}
|
|
294
|
+
className="gap-1"
|
|
295
|
+
>
|
|
296
|
+
{isLoadingCheck ? (
|
|
297
|
+
<>
|
|
298
|
+
<LoadingSpinner className="h-3 w-3" />
|
|
299
|
+
Running...
|
|
300
|
+
</>
|
|
301
|
+
) : (
|
|
302
|
+
<>
|
|
303
|
+
<Play className="h-3 w-3" />
|
|
304
|
+
Run Check
|
|
305
|
+
</>
|
|
306
|
+
)}
|
|
307
|
+
</Button>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
{/* Individual Check Result */}
|
|
311
|
+
<CollapsibleContent>
|
|
312
|
+
{individualResult && (
|
|
313
|
+
<div className="mt-2 p-3 bg-background rounded border">
|
|
314
|
+
<div className="flex items-center justify-between mb-2">
|
|
315
|
+
<div className="flex items-center gap-2">
|
|
316
|
+
{statusIcons[individualResult.status]}
|
|
317
|
+
<Badge className={statusColors[individualResult.status]} variant="outline">
|
|
318
|
+
{individualResult.status.toUpperCase()}
|
|
319
|
+
</Badge>
|
|
320
|
+
</div>
|
|
321
|
+
{individualResult.responseTimeMs && (
|
|
322
|
+
<span className="text-xs text-muted-foreground">
|
|
323
|
+
{individualResult.responseTimeMs}ms
|
|
324
|
+
</span>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
{individualResult.error && (
|
|
328
|
+
<p className="text-sm text-red-600 mt-1">{individualResult.error}</p>
|
|
329
|
+
)}
|
|
330
|
+
{individualResult.httpStatus && (
|
|
331
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
332
|
+
HTTP Status: {individualResult.httpStatus}
|
|
333
|
+
</p>
|
|
334
|
+
)}
|
|
335
|
+
{individualResult.timestamp && (
|
|
336
|
+
<p className="text-xs text-muted-foreground mt-1">
|
|
337
|
+
Checked at: {new Date(individualResult.timestamp).toLocaleString()}
|
|
338
|
+
</p>
|
|
339
|
+
)}
|
|
340
|
+
{individualResult.data && (
|
|
341
|
+
<div className="mt-2">
|
|
342
|
+
<pre className="p-2 rounded bg-muted text-xs overflow-auto max-h-[200px] whitespace-pre-wrap">
|
|
343
|
+
{JSON.stringify(individualResult.data, null, 2)}
|
|
344
|
+
</pre>
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
</CollapsibleContent>
|
|
350
|
+
</Collapsible>
|
|
351
|
+
</div>
|
|
352
|
+
)
|
|
353
|
+
})}
|
|
354
|
+
</div>
|
|
355
|
+
</CollapsibleContent>
|
|
356
|
+
</Collapsible>
|
|
357
|
+
</Card>
|
|
358
|
+
)
|
|
359
|
+
})}
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
)
|
|
363
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Health check service configuration placeholder
|
|
2
|
+
export const SERVICES = [
|
|
3
|
+
{
|
|
4
|
+
name: 'Clerk Authentication',
|
|
5
|
+
icon: 'key',
|
|
6
|
+
checks: [
|
|
7
|
+
{ name: 'Environment Config', endpoint: '/clerk/env' },
|
|
8
|
+
{ name: 'API Connectivity', endpoint: '/clerk/api-status' },
|
|
9
|
+
{ name: 'Get Current User', endpoint: '/clerk/user' },
|
|
10
|
+
{ name: 'Get Org Membership', endpoint: '/clerk/org' },
|
|
11
|
+
{ name: 'List Org Members', endpoint: '/clerk/members' },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'Supabase Database',
|
|
16
|
+
icon: 'database',
|
|
17
|
+
checks: [
|
|
18
|
+
{ name: 'Write Test Row', endpoint: '/database/write' },
|
|
19
|
+
{ name: 'Read Test Row', endpoint: '/database/read' },
|
|
20
|
+
{ name: 'Delete Test Row', endpoint: '/database/delete' },
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
name: 'Supabase Storage',
|
|
25
|
+
icon: 'folder',
|
|
26
|
+
checks: [
|
|
27
|
+
{ name: 'Upload Test File', endpoint: '/storage/upload' },
|
|
28
|
+
{ name: 'Download Test File', endpoint: '/storage/download' },
|
|
29
|
+
{ name: 'Delete Test File', endpoint: '/storage/delete' },
|
|
30
|
+
],
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'Supabase Edge Functions',
|
|
34
|
+
icon: 'zap',
|
|
35
|
+
checks: [
|
|
36
|
+
{ name: 'Ping Edge Function', endpoint: '/edge/ping' },
|
|
37
|
+
{ name: 'Test Auth Header', endpoint: '/edge/auth' },
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'Snowflake',
|
|
42
|
+
icon: 'snowflake',
|
|
43
|
+
checks: [
|
|
44
|
+
{ name: 'Test Connection', endpoint: '/snowflake/connect' },
|
|
45
|
+
{ name: 'Execute Query', endpoint: '/snowflake/query' },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'NextBank',
|
|
50
|
+
icon: 'building',
|
|
51
|
+
checks: [
|
|
52
|
+
{ name: 'Ping API', endpoint: '/nextbank/ping' },
|
|
53
|
+
{ name: 'Test Authentication', endpoint: '/nextbank/auth' },
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'Framework',
|
|
58
|
+
icon: 'code',
|
|
59
|
+
checks: [
|
|
60
|
+
{ name: 'Hono API', endpoint: '/framework/hono' },
|
|
61
|
+
{ name: 'Drizzle ORM', endpoint: '/framework/drizzle' },
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
name: 'Client State',
|
|
66
|
+
icon: 'cpu',
|
|
67
|
+
checks: [
|
|
68
|
+
{ name: 'Zustand Store', endpoint: 'client:zustand' },
|
|
69
|
+
{ name: 'TanStack Query', endpoint: 'client:query' },
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
] as const
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { create } from 'zustand'
|
|
2
|
+
|
|
3
|
+
interface HealthStore {
|
|
4
|
+
counter: number
|
|
5
|
+
increment: () => void
|
|
6
|
+
reset: () => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const useHealthStore = create<HealthStore>((set) => ({
|
|
10
|
+
counter: 0,
|
|
11
|
+
increment: () => set((state) => ({ counter: state.counter + 1 })),
|
|
12
|
+
reset: () => set({ counter: 0 }),
|
|
13
|
+
}))
|
|
14
|
+
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Health check types placeholder
|
|
2
|
+
export type HealthStatus = 'healthy' | 'unhealthy' | 'error' | 'pending' | 'unconfigured'
|
|
3
|
+
|
|
4
|
+
export interface ServiceCheck {
|
|
5
|
+
name: string
|
|
6
|
+
endpoint: string
|
|
7
|
+
status: HealthStatus
|
|
8
|
+
responseTimeMs?: number
|
|
9
|
+
error?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ServiceHealth {
|
|
13
|
+
name: string
|
|
14
|
+
icon: string
|
|
15
|
+
status: HealthStatus
|
|
16
|
+
responseTimeMs: number
|
|
17
|
+
checks: ServiceCheck[]
|
|
18
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { renderHook } from '@testing-library/react';
|
|
3
|
+
import { useDebounce } from '../utils/use-debounce';
|
|
4
|
+
|
|
5
|
+
describe('useDebounce hook', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.restoreAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('should return initial value immediately', () => {
|
|
15
|
+
const { result } = renderHook(() => useDebounce('test', 500));
|
|
16
|
+
expect(result.current).toBe('test');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should cleanup timer on unmount', () => {
|
|
20
|
+
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
|
|
21
|
+
|
|
22
|
+
const { unmount } = renderHook(() => useDebounce('test', 500));
|
|
23
|
+
|
|
24
|
+
unmount();
|
|
25
|
+
|
|
26
|
+
expect(clearTimeoutSpy).toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Health checks query hook placeholder
|
|
2
|
+
'use client'
|
|
3
|
+
|
|
4
|
+
import { useQuery } from '@tanstack/react-query'
|
|
5
|
+
|
|
6
|
+
export function useHealthChecks() {
|
|
7
|
+
return useQuery({
|
|
8
|
+
queryKey: ['health-checks'],
|
|
9
|
+
queryFn: async () => {
|
|
10
|
+
const response = await fetch('/api/health/all')
|
|
11
|
+
if (!response.ok) throw new Error('Failed to fetch health checks')
|
|
12
|
+
return response.json()
|
|
13
|
+
},
|
|
14
|
+
enabled: false,
|
|
15
|
+
})
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Debounce hook placeholder
|
|
2
|
+
'use client'
|
|
3
|
+
|
|
4
|
+
import { useEffect, useState } from 'react'
|
|
5
|
+
|
|
6
|
+
export function useDebounce<T>(value: T, delay: number = 500): T {
|
|
7
|
+
const [debouncedValue, setDebouncedValue] = useState<T>(value)
|
|
8
|
+
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
const handler = setTimeout(() => {
|
|
11
|
+
setDebouncedValue(value)
|
|
12
|
+
}, delay)
|
|
13
|
+
|
|
14
|
+
return () => {
|
|
15
|
+
clearTimeout(handler)
|
|
16
|
+
}
|
|
17
|
+
}, [value, delay])
|
|
18
|
+
|
|
19
|
+
return debouncedValue
|
|
20
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { cn } from '../utils';
|
|
3
|
+
|
|
4
|
+
describe('cn utility function', () => {
|
|
5
|
+
it('should merge class names correctly', () => {
|
|
6
|
+
expect(cn('foo', 'bar')).toBe('foo bar');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('should handle conditional classes', () => {
|
|
10
|
+
expect(cn('foo', false && 'bar', 'baz')).toBe('foo baz');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should handle undefined and null values', () => {
|
|
14
|
+
expect(cn('foo', undefined, null, 'bar')).toBe('foo bar');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('should handle empty strings', () => {
|
|
18
|
+
expect(cn('foo', '', 'bar')).toBe('foo bar');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should merge Tailwind classes correctly - later classes should override earlier ones', () => {
|
|
22
|
+
expect(cn('px-2 py-1', 'px-4')).toBe('py-1 px-4');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should handle arrays of classes', () => {
|
|
26
|
+
expect(cn(['foo', 'bar'], 'baz')).toBe('foo bar baz');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should handle objects with conditional classes', () => {
|
|
30
|
+
expect(cn({ foo: true, bar: false, baz: true })).toBe('foo baz');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should handle mixed input types', () => {
|
|
34
|
+
expect(cn('foo', { bar: true }, ['baz'], undefined)).toBe('foo bar baz');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should handle conflicting Tailwind utility classes', () => {
|
|
38
|
+
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should handle responsive classes', () => {
|
|
42
|
+
expect(cn('px-2', 'md:px-4', 'lg:px-6')).toBe('px-2 md:px-4 lg:px-6');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should return empty string when no valid classes provided', () => {
|
|
46
|
+
expect(cn('', false, null, undefined)).toBe('');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should handle numbers converted to strings', () => {
|
|
50
|
+
expect(cn('foo', 123 as any)).toBe('foo 123');
|
|
51
|
+
});
|
|
52
|
+
});
|