@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.
@@ -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