Finalize diagnostics, logging controls, and email test support
This commit is contained in:
417
frontend/app/ui/AdminDiagnosticsPanel.tsx
Normal file
417
frontend/app/ui/AdminDiagnosticsPanel.tsx
Normal file
@@ -0,0 +1,417 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
|
||||
type DiagnosticCatalogItem = {
|
||||
key: string
|
||||
label: string
|
||||
category: string
|
||||
description: string
|
||||
live_safe: boolean
|
||||
target: string | null
|
||||
configured: boolean
|
||||
config_status: string
|
||||
config_detail: string
|
||||
}
|
||||
|
||||
type DiagnosticResult = {
|
||||
key: string
|
||||
label: string
|
||||
category: string
|
||||
description: string
|
||||
target: string | null
|
||||
live_safe: boolean
|
||||
configured: boolean
|
||||
status: string
|
||||
message: string
|
||||
detail?: unknown
|
||||
checked_at?: string
|
||||
duration_ms?: number
|
||||
}
|
||||
|
||||
type DiagnosticsResponse = {
|
||||
checks: DiagnosticCatalogItem[]
|
||||
categories: string[]
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
type RunDiagnosticsResponse = {
|
||||
results: DiagnosticResult[]
|
||||
summary: {
|
||||
total: number
|
||||
up: number
|
||||
down: number
|
||||
degraded: number
|
||||
not_configured: number
|
||||
disabled: number
|
||||
}
|
||||
checked_at: string
|
||||
}
|
||||
|
||||
type RunMode = 'safe' | 'all' | 'single'
|
||||
|
||||
type AdminDiagnosticsPanelProps = {
|
||||
embedded?: boolean
|
||||
}
|
||||
|
||||
const REFRESH_INTERVAL_MS = 30000
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
idle: 'Ready',
|
||||
up: 'Up',
|
||||
down: 'Down',
|
||||
degraded: 'Degraded',
|
||||
disabled: 'Disabled',
|
||||
not_configured: 'Not configured',
|
||||
}
|
||||
|
||||
function formatCheckedAt(value?: string) {
|
||||
if (!value) return 'Not yet run'
|
||||
const parsed = new Date(value)
|
||||
if (Number.isNaN(parsed.getTime())) return value
|
||||
return parsed.toLocaleString()
|
||||
}
|
||||
|
||||
function formatDuration(value?: number) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value) || value <= 0) {
|
||||
return 'Pending'
|
||||
}
|
||||
return `${value.toFixed(1)} ms`
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return STATUS_LABELS[status] ?? status
|
||||
}
|
||||
|
||||
export default function AdminDiagnosticsPanel({ embedded = false }: AdminDiagnosticsPanelProps) {
|
||||
const router = useRouter()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [authorized, setAuthorized] = useState(false)
|
||||
const [checks, setChecks] = useState<DiagnosticCatalogItem[]>([])
|
||||
const [resultsByKey, setResultsByKey] = useState<Record<string, DiagnosticResult>>({})
|
||||
const [runningKeys, setRunningKeys] = useState<string[]>([])
|
||||
const [autoRefresh, setAutoRefresh] = useState(true)
|
||||
const [pageError, setPageError] = useState('')
|
||||
const [lastRunAt, setLastRunAt] = useState<string | null>(null)
|
||||
const [lastRunMode, setLastRunMode] = useState<RunMode | null>(null)
|
||||
const [emailRecipient, setEmailRecipient] = useState('')
|
||||
|
||||
const liveSafeKeys = checks.filter((check) => check.live_safe).map((check) => check.key)
|
||||
|
||||
async function runDiagnostics(keys?: string[], mode: RunMode = 'single') {
|
||||
const baseUrl = getApiBase()
|
||||
const effectiveKeys = keys && keys.length > 0 ? keys : checks.map((check) => check.key)
|
||||
if (effectiveKeys.length === 0) {
|
||||
return
|
||||
}
|
||||
setRunningKeys((current) => Array.from(new Set([...current, ...effectiveKeys])))
|
||||
setPageError('')
|
||||
try {
|
||||
const response = await authFetch(`${baseUrl}/admin/diagnostics/run`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
keys: effectiveKeys,
|
||||
...(emailRecipient.trim() ? { recipient_email: emailRecipient.trim() } : {}),
|
||||
}),
|
||||
})
|
||||
if (response.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || `Diagnostics run failed: ${response.status}`)
|
||||
}
|
||||
const data = (await response.json()) as { status: string } & RunDiagnosticsResponse
|
||||
const nextResults: Record<string, DiagnosticResult> = {}
|
||||
for (const result of data.results ?? []) {
|
||||
nextResults[result.key] = result
|
||||
}
|
||||
setResultsByKey((current) => ({ ...current, ...nextResults }))
|
||||
setLastRunAt(data.checked_at ?? new Date().toISOString())
|
||||
setLastRunMode(mode)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
setPageError(error instanceof Error ? error.message : 'Diagnostics run failed.')
|
||||
} finally {
|
||||
setRunningKeys((current) => current.filter((key) => !effectiveKeys.includes(key)))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
const loadPage = async () => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const authResponse = await authFetch(`${baseUrl}/auth/me`)
|
||||
if (!authResponse.ok) {
|
||||
if (authResponse.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
const me = await authResponse.json()
|
||||
if (!active) return
|
||||
if (me?.role !== 'admin') {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
const diagnosticsResponse = await authFetch(`${baseUrl}/admin/diagnostics`)
|
||||
if (!diagnosticsResponse.ok) {
|
||||
const text = await diagnosticsResponse.text()
|
||||
throw new Error(text || `Diagnostics load failed: ${diagnosticsResponse.status}`)
|
||||
}
|
||||
const data = (await diagnosticsResponse.json()) as { status: string } & DiagnosticsResponse
|
||||
if (!active) return
|
||||
setChecks(data.checks ?? [])
|
||||
setAuthorized(true)
|
||||
setLoading(false)
|
||||
const safeKeys = (data.checks ?? []).filter((check) => check.live_safe).map((check) => check.key)
|
||||
if (safeKeys.length > 0) {
|
||||
void runDiagnostics(safeKeys, 'safe')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
if (!active) return
|
||||
setPageError(error instanceof Error ? error.message : 'Unable to load diagnostics.')
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadPage()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!authorized || !autoRefresh || liveSafeKeys.length === 0) {
|
||||
return
|
||||
}
|
||||
const interval = window.setInterval(() => {
|
||||
void runDiagnostics(liveSafeKeys, 'safe')
|
||||
}, REFRESH_INTERVAL_MS)
|
||||
return () => {
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
}, [authorized, autoRefresh, liveSafeKeys.join('|')])
|
||||
|
||||
if (loading) {
|
||||
return <div className="admin-panel">Loading diagnostics...</div>
|
||||
}
|
||||
|
||||
if (!authorized) {
|
||||
return null
|
||||
}
|
||||
|
||||
const orderedCategories: string[] = []
|
||||
for (const check of checks) {
|
||||
if (!orderedCategories.includes(check.category)) {
|
||||
orderedCategories.push(check.category)
|
||||
}
|
||||
}
|
||||
|
||||
const mergedResults = checks.map((check) => {
|
||||
const result = resultsByKey[check.key]
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
return {
|
||||
key: check.key,
|
||||
label: check.label,
|
||||
category: check.category,
|
||||
description: check.description,
|
||||
target: check.target,
|
||||
live_safe: check.live_safe,
|
||||
configured: check.configured,
|
||||
status: check.configured ? 'idle' : check.config_status,
|
||||
message: check.configured ? 'Ready to test.' : check.config_detail,
|
||||
checked_at: undefined,
|
||||
duration_ms: undefined,
|
||||
} satisfies DiagnosticResult
|
||||
})
|
||||
|
||||
const summary = {
|
||||
total: mergedResults.length,
|
||||
up: 0,
|
||||
down: 0,
|
||||
degraded: 0,
|
||||
disabled: 0,
|
||||
not_configured: 0,
|
||||
idle: 0,
|
||||
}
|
||||
for (const result of mergedResults) {
|
||||
const key = result.status as keyof typeof summary
|
||||
if (key in summary) {
|
||||
summary[key] += 1
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`diagnostics-page${embedded ? ' diagnostics-page-embedded' : ''}`}>
|
||||
<div className="admin-panel diagnostics-control-panel">
|
||||
<div className="diagnostics-control-copy">
|
||||
<h2>{embedded ? 'Connectivity diagnostics' : 'Control center'}</h2>
|
||||
<p className="lede">
|
||||
Use live checks for Magent and service connectivity. Use run all when you want outbound notification
|
||||
channels to send a real ping through the configured provider.
|
||||
</p>
|
||||
</div>
|
||||
<div className="diagnostics-control-actions">
|
||||
<label className="diagnostics-email-recipient">
|
||||
<span>Test email recipient</span>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Leave blank to use configured sender"
|
||||
value={emailRecipient}
|
||||
onChange={(event) => setEmailRecipient(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className={autoRefresh ? 'is-active' : ''}
|
||||
onClick={() => setAutoRefresh((current) => !current)}
|
||||
>
|
||||
{autoRefresh ? 'Disable auto refresh' : 'Enable auto refresh'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void runDiagnostics(liveSafeKeys, 'safe')
|
||||
}}
|
||||
disabled={runningKeys.length > 0 || liveSafeKeys.length === 0}
|
||||
>
|
||||
Run live checks
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void runDiagnostics(undefined, 'all')
|
||||
}}
|
||||
disabled={runningKeys.length > 0 || checks.length === 0}
|
||||
>
|
||||
Run all tests
|
||||
</button>
|
||||
<span className={`small-pill ${autoRefresh ? 'is-positive' : ''}`}>
|
||||
{autoRefresh ? 'Auto refresh on' : 'Auto refresh off'}
|
||||
</span>
|
||||
<span className="small-pill">{lastRunMode ? `Last run: ${lastRunMode}` : 'No run yet'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-panel diagnostics-inline-summary">
|
||||
<div className="diagnostics-inline-metric">
|
||||
<span>Total</span>
|
||||
<strong>{summary.total}</strong>
|
||||
</div>
|
||||
<div className="diagnostics-inline-metric">
|
||||
<span>Up</span>
|
||||
<strong>{summary.up}</strong>
|
||||
</div>
|
||||
<div className="diagnostics-inline-metric">
|
||||
<span>Degraded</span>
|
||||
<strong>{summary.degraded}</strong>
|
||||
</div>
|
||||
<div className="diagnostics-inline-metric">
|
||||
<span>Down</span>
|
||||
<strong>{summary.down}</strong>
|
||||
</div>
|
||||
<div className="diagnostics-inline-metric">
|
||||
<span>Disabled</span>
|
||||
<strong>{summary.disabled}</strong>
|
||||
</div>
|
||||
<div className="diagnostics-inline-metric">
|
||||
<span>Not configured</span>
|
||||
<strong>{summary.not_configured}</strong>
|
||||
</div>
|
||||
<div className="diagnostics-inline-last-run">
|
||||
Last completed run: {formatCheckedAt(lastRunAt ?? undefined)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pageError ? <div className="admin-panel diagnostics-error">{pageError}</div> : null}
|
||||
|
||||
{orderedCategories.map((category) => {
|
||||
const categoryChecks = mergedResults.filter((check) => check.category === category)
|
||||
return (
|
||||
<div key={category} className="admin-panel diagnostics-category-panel">
|
||||
<div className="diagnostics-category-header">
|
||||
<div>
|
||||
<h2>{category}</h2>
|
||||
<p>{category === 'Notifications' ? 'These tests can emit real messages.' : 'Safe live health checks.'}</p>
|
||||
</div>
|
||||
<span className="small-pill">{categoryChecks.length} checks</span>
|
||||
</div>
|
||||
|
||||
<div className="diagnostics-grid">
|
||||
{categoryChecks.map((check) => {
|
||||
const isRunning = runningKeys.includes(check.key)
|
||||
return (
|
||||
<article key={check.key} className={`diagnostic-card diagnostic-card-${check.status}`}>
|
||||
<div className="diagnostic-card-top">
|
||||
<div className="diagnostic-card-copy">
|
||||
<div className="diagnostic-card-title-row">
|
||||
<h3>{check.label}</h3>
|
||||
<span className={`system-pill system-pill-${check.status}`}>{statusLabel(check.status)}</span>
|
||||
</div>
|
||||
<p>{check.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="system-test"
|
||||
onClick={() => {
|
||||
void runDiagnostics([check.key], 'single')
|
||||
}}
|
||||
disabled={isRunning}
|
||||
>
|
||||
{check.live_safe ? 'Ping' : 'Send test'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="diagnostic-meta-grid">
|
||||
<div className="diagnostic-meta-item">
|
||||
<span>Target</span>
|
||||
<strong>{check.target || 'Not set'}</strong>
|
||||
</div>
|
||||
<div className="diagnostic-meta-item">
|
||||
<span>Latency</span>
|
||||
<strong>{formatDuration(check.duration_ms)}</strong>
|
||||
</div>
|
||||
<div className="diagnostic-meta-item">
|
||||
<span>Mode</span>
|
||||
<strong>{check.live_safe ? 'Live safe' : 'Manual only'}</strong>
|
||||
</div>
|
||||
<div className="diagnostic-meta-item">
|
||||
<span>Last checked</span>
|
||||
<strong>{formatCheckedAt(check.checked_at)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`diagnostic-message diagnostic-message-${check.status}`}>
|
||||
<span className="system-dot" />
|
||||
<span>{isRunning ? 'Running diagnostic...' : check.message}</span>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user