@open-mercato/ui 0.7.1-develop.7150.1.c1941e0c22 → 0.7.1-develop.7152.1.a69e92f9c9
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/.turbo/turbo-build.log +1 -1
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +332 -0
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +7 -0
- package/dist/backend/dev/DevRuntimeReporter.js +36 -0
- package/dist/backend/dev/DevRuntimeReporter.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +495 -0
- package/src/backend/__tests__/DevRuntimeReporter.test.tsx +145 -0
- package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +412 -0
- package/src/backend/dev/DevRuntimeReporter.tsx +46 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { ChevronDown, ChevronUp, Database, RefreshCw, RotateCcw, ScrollText, Wrench, X } from 'lucide-react'
|
|
4
|
+
import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
|
|
5
|
+
import {
|
|
6
|
+
isDevRuntimeBannerEnabled,
|
|
7
|
+
readDevRuntimeLogsUrl,
|
|
8
|
+
readDevRuntimeToken,
|
|
9
|
+
} from '@open-mercato/shared/lib/dev-runtime/report'
|
|
10
|
+
import {
|
|
11
|
+
DEV_RUNTIME_ACTIONS_PATH,
|
|
12
|
+
DEV_RUNTIME_LOGS_PATH,
|
|
13
|
+
DEV_RUNTIME_STATUS_PATH,
|
|
14
|
+
DEV_RUNTIME_TOKEN_HEADER,
|
|
15
|
+
type RuntimeHealth,
|
|
16
|
+
type RuntimeIssue,
|
|
17
|
+
type DevRuntimeLogSnapshot,
|
|
18
|
+
type RuntimeRecoveryAction,
|
|
19
|
+
type RuntimeStatus,
|
|
20
|
+
} from '@open-mercato/shared/lib/dev-runtime/types'
|
|
21
|
+
import { Button } from '../../primitives/button'
|
|
22
|
+
import { IconButton } from '../../primitives/icon-button'
|
|
23
|
+
import { useConfirmDialog } from '../confirm-dialog'
|
|
24
|
+
import { apiCall } from '../utils/apiCall'
|
|
25
|
+
|
|
26
|
+
const ACTION_ICONS: Record<RuntimeRecoveryAction, typeof RefreshCw> = {
|
|
27
|
+
generate: Wrench,
|
|
28
|
+
migrate: Database,
|
|
29
|
+
restart: RotateCcw,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// `restart` is always safe to offer; `generate` and `migrate` appear only when
|
|
33
|
+
// the classifier justified them for this incident. `migrate` additionally
|
|
34
|
+
// requires a confirmation surface — the shared dialog needs the i18n provider,
|
|
35
|
+
// so a provider-less tree gets no irreversible action rather than an
|
|
36
|
+
// unconfirmed one.
|
|
37
|
+
function resolveOfferedActions(
|
|
38
|
+
issue: RuntimeIssue | null,
|
|
39
|
+
{ canConfirm }: { canConfirm: boolean },
|
|
40
|
+
): RuntimeRecoveryAction[] {
|
|
41
|
+
const actions: RuntimeRecoveryAction[] = []
|
|
42
|
+
if (issue?.recovery === 'generate') actions.push('generate')
|
|
43
|
+
if (issue?.recovery === 'migrate' && canConfirm) actions.push('migrate')
|
|
44
|
+
actions.push('restart')
|
|
45
|
+
return actions
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const POLL_INTERVAL_MS = 2000
|
|
49
|
+
|
|
50
|
+
const VISIBLE_HEALTH: RuntimeHealth[] = ['starting', 'degraded', 'recovering', 'unavailable']
|
|
51
|
+
|
|
52
|
+
type BannerTone = 'info' | 'warning' | 'error'
|
|
53
|
+
|
|
54
|
+
const HEALTH_TONE: Record<RuntimeHealth, BannerTone> = {
|
|
55
|
+
starting: 'info',
|
|
56
|
+
ready: 'info',
|
|
57
|
+
degraded: 'warning',
|
|
58
|
+
recovering: 'info',
|
|
59
|
+
unavailable: 'error',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TONE_CLASSES: Record<BannerTone, string> = {
|
|
63
|
+
info: 'border-status-info-border bg-status-info-bg text-status-info-text',
|
|
64
|
+
warning: 'border-status-warning-border bg-status-warning-bg text-status-warning-text',
|
|
65
|
+
error: 'border-status-error-border bg-status-error-bg text-status-error-text',
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const TONE_ACTION_CLASSES: Record<BannerTone, string> = {
|
|
69
|
+
info: 'border-status-info-border bg-status-info-bg text-status-info-text hover:bg-status-info-border hover:text-status-info-text',
|
|
70
|
+
warning: 'border-status-warning-border bg-status-warning-bg text-status-warning-text hover:bg-status-warning-border hover:text-status-warning-text',
|
|
71
|
+
error: 'border-status-error-border bg-status-error-bg text-status-error-text hover:bg-status-error-border hover:text-status-error-text',
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function dismissalKey(status: RuntimeStatus, issue: RuntimeIssue | null): string {
|
|
75
|
+
return `${status.generation}:${issue?.fingerprint ?? status.health}`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// The dev bridge answers 403 whenever the per-run token is stale — routine after
|
|
79
|
+
// a `yarn dev` restart leaves an already-open tab holding the previous run's
|
|
80
|
+
// token — and 404 once diagnostics are off. Neither is a staff-auth event, so
|
|
81
|
+
// both of `apiFetch`'s redirect hooks are switched off. Without this it throws
|
|
82
|
+
// `ForbiddenError` instead of returning the response, the poll below swallows
|
|
83
|
+
// the throw, and the banner freezes on the dead runtime's incident rather than
|
|
84
|
+
// clearing itself.
|
|
85
|
+
function devRuntimeRequestHeaders(token: string): Record<string, string> {
|
|
86
|
+
return {
|
|
87
|
+
[DEV_RUNTIME_TOKEN_HEADER]: token,
|
|
88
|
+
'x-om-unauthorized-redirect': '0',
|
|
89
|
+
'x-om-forbidden-redirect': '0',
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function fetchRuntimeStatus(token: string, signal: AbortSignal): Promise<RuntimeStatus | null> {
|
|
94
|
+
const response = await apiCall<RuntimeStatus>(DEV_RUNTIME_STATUS_PATH, {
|
|
95
|
+
headers: devRuntimeRequestHeaders(token),
|
|
96
|
+
cache: 'no-store',
|
|
97
|
+
signal,
|
|
98
|
+
})
|
|
99
|
+
if (!response.ok) return null
|
|
100
|
+
return response.result
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function useRuntimeStatus(token: string | null): RuntimeStatus | null {
|
|
104
|
+
const [status, setStatus] = React.useState<RuntimeStatus | null>(null)
|
|
105
|
+
|
|
106
|
+
React.useEffect(() => {
|
|
107
|
+
if (!token) return undefined
|
|
108
|
+
let cancelled = false
|
|
109
|
+
const controller = new AbortController()
|
|
110
|
+
|
|
111
|
+
const poll = async () => {
|
|
112
|
+
try {
|
|
113
|
+
const next = await fetchRuntimeStatus(token, controller.signal)
|
|
114
|
+
if (!cancelled) setStatus(next)
|
|
115
|
+
} catch {
|
|
116
|
+
// A momentarily unreachable bridge must never break the page: keep the
|
|
117
|
+
// last known status and try again on the next tick.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
void poll()
|
|
122
|
+
const timer = setInterval(() => { void poll() }, POLL_INTERVAL_MS)
|
|
123
|
+
return () => {
|
|
124
|
+
cancelled = true
|
|
125
|
+
controller.abort()
|
|
126
|
+
clearInterval(timer)
|
|
127
|
+
}
|
|
128
|
+
}, [token])
|
|
129
|
+
|
|
130
|
+
return status
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function reloadPage(): void {
|
|
134
|
+
if (typeof window === 'undefined') return
|
|
135
|
+
try {
|
|
136
|
+
window.location.reload()
|
|
137
|
+
} catch {
|
|
138
|
+
// Reload is a convenience affordance; ignore hosts that block it.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Dev-only, in-app counterpart to the standalone startup splash. It reports the
|
|
144
|
+
* supervisor's runtime state on an already-open page so a post-ready failure is
|
|
145
|
+
* visible without switching to the terminal. It never renders in production and
|
|
146
|
+
* never renders while the runtime is healthy.
|
|
147
|
+
*/
|
|
148
|
+
export function DevRuntimeDiagnosticsBanner() {
|
|
149
|
+
// The banner must render even when a broken tree left the app without its
|
|
150
|
+
// i18n provider, so the translator is optional with inline English fallbacks.
|
|
151
|
+
const translate = useOptionalT()
|
|
152
|
+
const t = React.useCallback(
|
|
153
|
+
(key: string, fallback: string) => (translate ? translate(key, fallback) : fallback),
|
|
154
|
+
[translate],
|
|
155
|
+
)
|
|
156
|
+
const [token, setToken] = React.useState<string | null>(null)
|
|
157
|
+
const [logsUrl, setLogsUrl] = React.useState<string | null>(null)
|
|
158
|
+
const [expanded, setExpanded] = React.useState(false)
|
|
159
|
+
const [dismissed, setDismissed] = React.useState<string | null>(null)
|
|
160
|
+
const [pendingAction, setPendingAction] = React.useState<RuntimeRecoveryAction | null>(null)
|
|
161
|
+
const [logs, setLogs] = React.useState<DevRuntimeLogSnapshot | null>(null)
|
|
162
|
+
const [logsOpen, setLogsOpen] = React.useState(false)
|
|
163
|
+
const [actionError, setActionError] = React.useState<string | null>(null)
|
|
164
|
+
const { confirm, ConfirmDialogElement } = useConfirmDialog()
|
|
165
|
+
// ConfirmDialog itself calls `useT`, so it can only be mounted where the
|
|
166
|
+
// provider exists.
|
|
167
|
+
const canConfirm = translate !== undefined
|
|
168
|
+
|
|
169
|
+
React.useEffect(() => {
|
|
170
|
+
if (!isDevRuntimeBannerEnabled()) return
|
|
171
|
+
setToken(readDevRuntimeToken())
|
|
172
|
+
setLogsUrl(readDevRuntimeLogsUrl())
|
|
173
|
+
}, [])
|
|
174
|
+
|
|
175
|
+
const status = useRuntimeStatus(token)
|
|
176
|
+
const issue = status?.issueSummary ?? null
|
|
177
|
+
const currentKey = status ? dismissalKey(status, issue) : null
|
|
178
|
+
|
|
179
|
+
// Dismissal is view-local and scoped to one generation:fingerprint, so a new
|
|
180
|
+
// incident — or the same one in a new generation — reappears.
|
|
181
|
+
React.useEffect(() => {
|
|
182
|
+
if (currentKey && dismissed && dismissed !== currentKey) setDismissed(null)
|
|
183
|
+
}, [currentKey, dismissed])
|
|
184
|
+
|
|
185
|
+
React.useEffect(() => {
|
|
186
|
+
if (status?.health === 'ready') setExpanded(false)
|
|
187
|
+
}, [status?.health])
|
|
188
|
+
|
|
189
|
+
// Logs are fetched on demand from the app itself, so opening them never
|
|
190
|
+
// navigates away from the page being debugged.
|
|
191
|
+
const toggleLogs = React.useCallback(async () => {
|
|
192
|
+
if (logsOpen) {
|
|
193
|
+
setLogsOpen(false)
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
setLogsOpen(true)
|
|
197
|
+
if (!token) return
|
|
198
|
+
try {
|
|
199
|
+
const response = await apiCall<DevRuntimeLogSnapshot>(`${DEV_RUNTIME_LOGS_PATH}?cursor=0`, {
|
|
200
|
+
headers: devRuntimeRequestHeaders(token),
|
|
201
|
+
cache: 'no-store',
|
|
202
|
+
})
|
|
203
|
+
setLogs(response.ok ? response.result : null)
|
|
204
|
+
} catch {
|
|
205
|
+
setLogs(null)
|
|
206
|
+
}
|
|
207
|
+
}, [logsOpen, token])
|
|
208
|
+
|
|
209
|
+
const runRecoveryAction = React.useCallback(async (action: RuntimeRecoveryAction) => {
|
|
210
|
+
if (!token || pendingAction) return
|
|
211
|
+
// `migrate` writes to the database and cannot be undone automatically, so it
|
|
212
|
+
// always goes through the shared confirmation dialog.
|
|
213
|
+
if (action === 'migrate') {
|
|
214
|
+
const confirmed = await confirm({
|
|
215
|
+
title: t('ui.devRuntime.confirm.migrate.title', 'Apply database migrations?'),
|
|
216
|
+
text: t(
|
|
217
|
+
'ui.devRuntime.confirm.migrate.text',
|
|
218
|
+
'This applies pending migrations to your development database. It is not automatically reversible — rolling back is a separate manual task.',
|
|
219
|
+
),
|
|
220
|
+
confirmText: t('ui.devRuntime.actions.migrate', 'Run migrations'),
|
|
221
|
+
variant: 'destructive',
|
|
222
|
+
})
|
|
223
|
+
if (!confirmed) return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
setPendingAction(action)
|
|
227
|
+
setActionError(null)
|
|
228
|
+
try {
|
|
229
|
+
const response = await apiCall<{ error?: { message?: string } }>(`${DEV_RUNTIME_ACTIONS_PATH}/${action}`, {
|
|
230
|
+
method: 'POST',
|
|
231
|
+
headers: devRuntimeRequestHeaders(token),
|
|
232
|
+
cache: 'no-store',
|
|
233
|
+
})
|
|
234
|
+
if (!response.ok) {
|
|
235
|
+
setActionError(response.result?.error?.message ?? t('ui.devRuntime.actions.failed', 'The recovery action could not be started.'))
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
setActionError(t('ui.devRuntime.actions.failed', 'The recovery action could not be started.'))
|
|
239
|
+
} finally {
|
|
240
|
+
setPendingAction(null)
|
|
241
|
+
}
|
|
242
|
+
}, [token, pendingAction, confirm, t])
|
|
243
|
+
|
|
244
|
+
if (!status || !VISIBLE_HEALTH.includes(status.health)) return null
|
|
245
|
+
if (currentKey && dismissed === currentKey) return null
|
|
246
|
+
|
|
247
|
+
const tone = HEALTH_TONE[status.health]
|
|
248
|
+
const isBusy = status.recovery?.busy === true
|
|
249
|
+
const headline = t(`ui.devRuntime.health.${status.health}`, DEFAULT_HEALTH_COPY[status.health])
|
|
250
|
+
const title = issue?.title ?? t('ui.devRuntime.noIncident', 'No incident details available')
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div
|
|
254
|
+
data-testid="dev-runtime-diagnostics-banner"
|
|
255
|
+
data-health={status.health}
|
|
256
|
+
role={status.health === 'unavailable' ? 'alert' : 'status'}
|
|
257
|
+
aria-live={status.health === 'unavailable' ? 'assertive' : 'polite'}
|
|
258
|
+
// Floating bottom-right dev overlay, lifted clear of the support-chat
|
|
259
|
+
// launcher that sits in that corner. Third-party launchers ship their own
|
|
260
|
+
// very high z-index, so the banner stacks ABOVE the bubble rather than
|
|
261
|
+
// trying to outrank it. `max-w-4xl` keeps the action row on one line on
|
|
262
|
+
// desktop; it still wraps (never scrolls) once the viewport is narrow.
|
|
263
|
+
className={`fixed inset-x-3 bottom-20 z-banner flex flex-col gap-2 rounded-lg border px-4 py-3 text-sm shadow-lg sm:inset-x-auto sm:right-4 sm:max-w-4xl ${TONE_CLASSES[tone]}`}
|
|
264
|
+
>
|
|
265
|
+
<div className="flex items-start justify-between gap-2">
|
|
266
|
+
<div className="min-w-0 flex-1">
|
|
267
|
+
<p className="font-medium">
|
|
268
|
+
{headline}
|
|
269
|
+
<span aria-hidden="true"> · </span>
|
|
270
|
+
{title}
|
|
271
|
+
</p>
|
|
272
|
+
{issue?.detail ? <p className="mt-0.5 break-words">{issue.detail}</p> : null}
|
|
273
|
+
</div>
|
|
274
|
+
{/* Dismiss stays pinned to the corner instead of joining the wrapping
|
|
275
|
+
action row, where it used to orphan onto a line of its own. */}
|
|
276
|
+
<IconButton
|
|
277
|
+
type="button"
|
|
278
|
+
variant="ghost"
|
|
279
|
+
size="sm"
|
|
280
|
+
aria-label={t('ui.devRuntime.actions.dismiss', 'Dismiss')}
|
|
281
|
+
onClick={() => setDismissed(currentKey)}
|
|
282
|
+
>
|
|
283
|
+
<X className="size-4" aria-hidden="true" />
|
|
284
|
+
</IconButton>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<div className="flex flex-wrap items-center gap-1">
|
|
288
|
+
{issue ? (
|
|
289
|
+
<Button
|
|
290
|
+
type="button"
|
|
291
|
+
variant="outline"
|
|
292
|
+
size="sm"
|
|
293
|
+
aria-expanded={expanded}
|
|
294
|
+
onClick={() => setExpanded((value) => !value)}
|
|
295
|
+
className={`whitespace-nowrap ${TONE_ACTION_CLASSES[tone]}`}
|
|
296
|
+
>
|
|
297
|
+
{expanded
|
|
298
|
+
? <ChevronUp className="mr-1 size-4" aria-hidden="true" />
|
|
299
|
+
: <ChevronDown className="mr-1 size-4" aria-hidden="true" />}
|
|
300
|
+
{expanded
|
|
301
|
+
? t('ui.devRuntime.actions.hideDetails', 'Hide details')
|
|
302
|
+
: t('ui.devRuntime.actions.showDetails', 'Show details')}
|
|
303
|
+
</Button>
|
|
304
|
+
) : null}
|
|
305
|
+
{!isBusy ? (
|
|
306
|
+
<Button
|
|
307
|
+
type="button"
|
|
308
|
+
variant="outline"
|
|
309
|
+
size="sm"
|
|
310
|
+
onClick={reloadPage}
|
|
311
|
+
className={`whitespace-nowrap ${TONE_ACTION_CLASSES[tone]}`}
|
|
312
|
+
>
|
|
313
|
+
<RefreshCw className="mr-1 size-4" aria-hidden="true" />
|
|
314
|
+
{t('ui.devRuntime.actions.retry', 'Retry')}
|
|
315
|
+
</Button>
|
|
316
|
+
) : null}
|
|
317
|
+
{!isBusy && token
|
|
318
|
+
? resolveOfferedActions(issue, { canConfirm }).map((action) => {
|
|
319
|
+
const ActionIcon = ACTION_ICONS[action]
|
|
320
|
+
return (
|
|
321
|
+
<Button
|
|
322
|
+
key={action}
|
|
323
|
+
type="button"
|
|
324
|
+
variant="outline"
|
|
325
|
+
size="sm"
|
|
326
|
+
disabled={pendingAction !== null}
|
|
327
|
+
onClick={() => { void runRecoveryAction(action) }}
|
|
328
|
+
className={`whitespace-nowrap ${TONE_ACTION_CLASSES[tone]}`}
|
|
329
|
+
>
|
|
330
|
+
<ActionIcon className="mr-1 size-4" aria-hidden="true" />
|
|
331
|
+
{t(`ui.devRuntime.actions.${action}`, DEFAULT_ACTION_COPY[action])}
|
|
332
|
+
</Button>
|
|
333
|
+
)
|
|
334
|
+
})
|
|
335
|
+
: null}
|
|
336
|
+
{token ? (
|
|
337
|
+
<Button
|
|
338
|
+
type="button"
|
|
339
|
+
variant="outline"
|
|
340
|
+
size="sm"
|
|
341
|
+
aria-expanded={logsOpen}
|
|
342
|
+
onClick={() => { void toggleLogs() }}
|
|
343
|
+
className={`whitespace-nowrap ${TONE_ACTION_CLASSES[tone]}`}
|
|
344
|
+
>
|
|
345
|
+
<ScrollText className="mr-1 size-4" aria-hidden="true" />
|
|
346
|
+
{logsOpen
|
|
347
|
+
? t('ui.devRuntime.actions.hideLogs', 'Hide logs')
|
|
348
|
+
: t('ui.devRuntime.actions.viewLogs', 'View logs')}
|
|
349
|
+
</Button>
|
|
350
|
+
) : null}
|
|
351
|
+
</div>
|
|
352
|
+
|
|
353
|
+
{actionError ? <p className="text-xs font-medium">{actionError}</p> : null}
|
|
354
|
+
|
|
355
|
+
{expanded && issue ? (
|
|
356
|
+
<dl className="grid grid-cols-1 gap-x-6 gap-y-1 text-xs sm:grid-cols-2">
|
|
357
|
+
<DetailRow label={t('ui.devRuntime.details.code', 'Error code')} value={issue.code} />
|
|
358
|
+
<DetailRow label={t('ui.devRuntime.details.source', 'Source')} value={issue.source} />
|
|
359
|
+
<DetailRow label={t('ui.devRuntime.details.occurrences', 'Occurrences')} value={String(issue.occurrences)} />
|
|
360
|
+
<DetailRow label={t('ui.devRuntime.details.generation', 'Runtime generation')} value={String(issue.generation)} />
|
|
361
|
+
<DetailRow label={t('ui.devRuntime.details.firstSeen', 'First seen')} value={issue.firstSeenAt} />
|
|
362
|
+
<DetailRow label={t('ui.devRuntime.details.lastSeen', 'Last seen')} value={issue.lastSeenAt} />
|
|
363
|
+
{issue.path ? <DetailRow label={t('ui.devRuntime.details.path', 'Path')} value={issue.path} /> : null}
|
|
364
|
+
</dl>
|
|
365
|
+
) : null}
|
|
366
|
+
{logsOpen ? (
|
|
367
|
+
<div className="rounded-md border border-current/20 bg-black/20">
|
|
368
|
+
{logs && logs.lines.length > 0 ? (
|
|
369
|
+
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words p-2 font-mono text-xs leading-relaxed">
|
|
370
|
+
{logs.lines.map((line) => `${line.at.slice(11, 19)} ${line.text}`).join('\n')}
|
|
371
|
+
</pre>
|
|
372
|
+
) : (
|
|
373
|
+
<p className="p-2 text-xs">{t('ui.devRuntime.logs.empty', 'No diagnostic lines yet.')}</p>
|
|
374
|
+
)}
|
|
375
|
+
{logsUrl ? (
|
|
376
|
+
<p className="border-t border-current/20 px-2 py-1 text-xs opacity-80">
|
|
377
|
+
{t('ui.devRuntime.logs.splashHint', 'Full startup stream:')}{' '}
|
|
378
|
+
<a className="underline" href={logsUrl} target="_blank" rel="noreferrer">{logsUrl}</a>
|
|
379
|
+
</p>
|
|
380
|
+
) : null}
|
|
381
|
+
</div>
|
|
382
|
+
) : null}
|
|
383
|
+
|
|
384
|
+
{canConfirm ? ConfirmDialogElement : null}
|
|
385
|
+
</div>
|
|
386
|
+
)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const DEFAULT_ACTION_COPY: Record<RuntimeRecoveryAction, string> = {
|
|
390
|
+
generate: 'Run generators',
|
|
391
|
+
migrate: 'Run migrations',
|
|
392
|
+
restart: 'Restart runtime',
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const DEFAULT_HEALTH_COPY: Record<RuntimeHealth, string> = {
|
|
396
|
+
starting: 'Runtime starting',
|
|
397
|
+
ready: 'Runtime ready',
|
|
398
|
+
degraded: 'Runtime degraded',
|
|
399
|
+
recovering: 'Runtime recovering',
|
|
400
|
+
unavailable: 'Runtime unavailable',
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function DetailRow({ label, value }: { label: string; value: string }) {
|
|
404
|
+
return (
|
|
405
|
+
<div className="flex gap-2">
|
|
406
|
+
<dt className="shrink-0 opacity-80">{label}</dt>
|
|
407
|
+
<dd className="min-w-0 break-words font-mono">{value}</dd>
|
|
408
|
+
</div>
|
|
409
|
+
)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export default DevRuntimeDiagnosticsBanner
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { reportDevRuntimeError } from '@open-mercato/shared/lib/dev-runtime/report'
|
|
4
|
+
|
|
5
|
+
function isChunkLoadFailure(message: string): boolean {
|
|
6
|
+
const haystack = message.toLowerCase()
|
|
7
|
+
return haystack.includes('chunkloaderror')
|
|
8
|
+
|| haystack.includes('loading chunk')
|
|
9
|
+
|| haystack.includes('loading css chunk')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Dev-only client island that forwards uncaught browser failures to the local
|
|
14
|
+
* supervisor. It registers bounded listeners only, adds no context provider,
|
|
15
|
+
* and stays silent when the collector token is absent (production, CI, or
|
|
16
|
+
* diagnostics disabled).
|
|
17
|
+
*/
|
|
18
|
+
export function DevRuntimeReporter() {
|
|
19
|
+
React.useEffect(() => {
|
|
20
|
+
if (typeof window === 'undefined') return undefined
|
|
21
|
+
|
|
22
|
+
const handleError = (event: ErrorEvent) => {
|
|
23
|
+
const message = event.message ?? ''
|
|
24
|
+
reportDevRuntimeError({
|
|
25
|
+
kind: isChunkLoadFailure(message) ? 'chunk-load-error' : 'window-error',
|
|
26
|
+
error: event.error,
|
|
27
|
+
message: message || undefined,
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const handleRejection = (event: PromiseRejectionEvent) => {
|
|
32
|
+
reportDevRuntimeError({ kind: 'unhandled-rejection', error: event.reason })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
window.addEventListener('error', handleError)
|
|
36
|
+
window.addEventListener('unhandledrejection', handleRejection)
|
|
37
|
+
return () => {
|
|
38
|
+
window.removeEventListener('error', handleError)
|
|
39
|
+
window.removeEventListener('unhandledrejection', handleRejection)
|
|
40
|
+
}
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default DevRuntimeReporter
|