Finalize diagnostics, logging controls, and email test support
This commit is contained in:
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
|
||||
import AdminShell from '../ui/AdminShell'
|
||||
import AdminDiagnosticsPanel from '../ui/AdminDiagnosticsPanel'
|
||||
|
||||
type AdminSetting = {
|
||||
key: string
|
||||
@@ -76,6 +77,8 @@ const NUMBER_SETTINGS = new Set([
|
||||
'magent_application_port',
|
||||
'magent_api_port',
|
||||
'magent_notify_email_smtp_port',
|
||||
'log_file_max_bytes',
|
||||
'log_file_backup_count',
|
||||
'requests_sync_ttl_minutes',
|
||||
'requests_poll_interval_seconds',
|
||||
'requests_delta_sync_interval_minutes',
|
||||
@@ -277,6 +280,10 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
||||
magent_notify_push_device: 'Device / target',
|
||||
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
|
||||
magent_notify_webhook_url: 'Generic webhook URL',
|
||||
log_file_max_bytes: 'Log file max size (bytes)',
|
||||
log_file_backup_count: 'Rotated log files to keep',
|
||||
log_http_client_level: 'Service HTTP log level',
|
||||
log_background_sync_level: 'Background sync log level',
|
||||
}
|
||||
|
||||
const labelFromKey = (key: string) =>
|
||||
@@ -329,11 +336,29 @@ type SettingsSectionGroup = {
|
||||
description?: string
|
||||
}
|
||||
|
||||
type SectionFeedback = {
|
||||
tone: 'status' | 'error'
|
||||
message: string
|
||||
}
|
||||
|
||||
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
|
||||
jellyseerr: 'seerr',
|
||||
jellyfin: 'jellyfin',
|
||||
sonarr: 'sonarr',
|
||||
radarr: 'radarr',
|
||||
prowlarr: 'prowlarr',
|
||||
qbittorrent: 'qbittorrent',
|
||||
}
|
||||
|
||||
export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
const router = useRouter()
|
||||
const [settings, setSettings] = useState<AdminSetting[]>([])
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({})
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [sectionFeedback, setSectionFeedback] = useState<Record<string, SectionFeedback>>({})
|
||||
const [sectionSaving, setSectionSaving] = useState<Record<string, boolean>>({})
|
||||
const [sectionTesting, setSectionTesting] = useState<Record<string, boolean>>({})
|
||||
const [emailTestRecipient, setEmailTestRecipient] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [sonarrOptions, setSonarrOptions] = useState<ServiceOptions | null>(null)
|
||||
const [radarrOptions, setRadarrOptions] = useState<ServiceOptions | null>(null)
|
||||
@@ -374,7 +399,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
return Math.max(0, Math.min(100, Math.round((completed / total) * 100)))
|
||||
}
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
const loadSettings = useCallback(async (refreshedKeys?: Set<string>) => {
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/admin/settings`)
|
||||
if (!response.ok) {
|
||||
@@ -404,7 +429,18 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
initialValues[setting.key] = ''
|
||||
}
|
||||
}
|
||||
setFormValues(initialValues)
|
||||
setFormValues((current) => {
|
||||
if (!refreshedKeys || refreshedKeys.size === 0) {
|
||||
return initialValues
|
||||
}
|
||||
const nextValues = { ...initialValues }
|
||||
for (const [key, value] of Object.entries(current)) {
|
||||
if (!refreshedKeys.has(key)) {
|
||||
nextValues[key] = value
|
||||
}
|
||||
}
|
||||
return nextValues
|
||||
})
|
||||
setStatus(null)
|
||||
}, [router])
|
||||
|
||||
@@ -701,6 +737,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
'Pick where Magent should read requests from. Cache-only avoids Seerr lookups on reads.',
|
||||
log_level: 'How much detail is written to the activity log.',
|
||||
log_file: 'Where the activity log is stored.',
|
||||
log_file_max_bytes: 'Rotate the log file when it reaches this size in bytes.',
|
||||
log_file_backup_count: 'How many rotated log files to retain on disk.',
|
||||
log_http_client_level:
|
||||
'Verbosity for per-call outbound service traffic logs from Seerr, Jellyfin, Sonarr, Radarr, and related clients.',
|
||||
log_background_sync_level:
|
||||
'Verbosity for scheduled background sync progress messages.',
|
||||
site_build_number: 'Build number shown in the account menu (auto-set from releases).',
|
||||
site_banner_enabled: 'Enable a sitewide banner for announcements.',
|
||||
site_banner_message: 'Short banner message for maintenance or updates.',
|
||||
@@ -725,6 +767,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
magent_notify_email_smtp_username: 'notifications@example.com',
|
||||
magent_notify_email_from_address: 'notifications@example.com',
|
||||
magent_notify_email_from_name: 'Magent',
|
||||
log_file_max_bytes: '20000000',
|
||||
log_file_backup_count: '10',
|
||||
magent_notify_discord_webhook_url: 'https://discord.com/api/webhooks/...',
|
||||
magent_notify_telegram_bot_token: '123456789:AA...',
|
||||
magent_notify_telegram_chat_id: '-1001234567890',
|
||||
@@ -762,23 +806,41 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
return list
|
||||
}
|
||||
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
setStatus(null)
|
||||
const parseActionError = (err: unknown, fallback: string) => {
|
||||
if (err instanceof Error && err.message) {
|
||||
return err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
const buildSettingsPayload = (items: AdminSetting[]) => {
|
||||
const payload: Record<string, string> = {}
|
||||
const formData = new FormData(event.currentTarget)
|
||||
for (const setting of settings) {
|
||||
const rawValue = formData.get(setting.key)
|
||||
for (const setting of items) {
|
||||
const rawValue = formValues[setting.key]
|
||||
if (typeof rawValue !== 'string') {
|
||||
continue
|
||||
}
|
||||
const value = rawValue.trim()
|
||||
if (value === '') {
|
||||
if (setting.sensitive && value === '') {
|
||||
continue
|
||||
}
|
||||
payload[setting.key] = value
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
const saveSettingGroup = async (
|
||||
sectionGroup: SettingsSectionGroup,
|
||||
options?: { successMessage?: string | null },
|
||||
) => {
|
||||
setSectionFeedback((current) => {
|
||||
const next = { ...current }
|
||||
delete next[sectionGroup.key]
|
||||
return next
|
||||
})
|
||||
setSectionSaving((current) => ({ ...current, [sectionGroup.key]: true }))
|
||||
try {
|
||||
const payload = buildSettingsPayload(sectionGroup.items)
|
||||
const baseUrl = getApiBase()
|
||||
const response = await authFetch(`${baseUrl}/admin/settings`, {
|
||||
method: 'PUT',
|
||||
@@ -789,15 +851,129 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Update failed')
|
||||
}
|
||||
setStatus('Settings saved. New values take effect immediately.')
|
||||
await loadSettings()
|
||||
await loadSettings(new Set(sectionGroup.items.map((item) => item.key)))
|
||||
if (options?.successMessage !== null) {
|
||||
setSectionFeedback((current) => ({
|
||||
...current,
|
||||
[sectionGroup.key]: {
|
||||
tone: 'status',
|
||||
message: options?.successMessage ?? `${sectionGroup.title} settings saved.`,
|
||||
},
|
||||
}))
|
||||
}
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
const message =
|
||||
err instanceof Error && err.message
|
||||
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
|
||||
: 'Could not save settings.'
|
||||
setStatus(message)
|
||||
setSectionFeedback((current) => ({
|
||||
...current,
|
||||
[sectionGroup.key]: {
|
||||
tone: 'error',
|
||||
message: parseActionError(err, 'Could not save settings.'),
|
||||
},
|
||||
}))
|
||||
return false
|
||||
} finally {
|
||||
setSectionSaving((current) => ({ ...current, [sectionGroup.key]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const formatServiceTestFeedback = (result: any): SectionFeedback => {
|
||||
const name = result?.name ?? 'Service'
|
||||
const state = String(result?.status ?? 'unknown').toLowerCase()
|
||||
if (state === 'up') {
|
||||
return { tone: 'status', message: `${name} connection test passed.` }
|
||||
}
|
||||
if (state === 'degraded') {
|
||||
return {
|
||||
tone: 'error',
|
||||
message: result?.message ? `${name}: ${result.message}` : `${name} reported warnings.`,
|
||||
}
|
||||
}
|
||||
if (state === 'not_configured') {
|
||||
return { tone: 'error', message: `${name} is not fully configured yet.` }
|
||||
}
|
||||
return {
|
||||
tone: 'error',
|
||||
message: result?.message ? `${name}: ${result.message}` : `${name} connection test failed.`,
|
||||
}
|
||||
}
|
||||
|
||||
const getSectionTestLabel = (sectionKey: string) => {
|
||||
if (sectionKey === 'magent-notify-email') {
|
||||
return 'Send test email'
|
||||
}
|
||||
if (sectionKey in SERVICE_TEST_ENDPOINTS) {
|
||||
return 'Test connection'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const testSettingGroup = async (sectionGroup: SettingsSectionGroup) => {
|
||||
setSectionFeedback((current) => {
|
||||
const next = { ...current }
|
||||
delete next[sectionGroup.key]
|
||||
return next
|
||||
})
|
||||
setSectionTesting((current) => ({ ...current, [sectionGroup.key]: true }))
|
||||
try {
|
||||
const saved = await saveSettingGroup(sectionGroup, { successMessage: null })
|
||||
if (!saved) {
|
||||
return
|
||||
}
|
||||
|
||||
const baseUrl = getApiBase()
|
||||
if (sectionGroup.key === 'magent-notify-email') {
|
||||
const recipientEmail =
|
||||
emailTestRecipient.trim() || formValues.magent_notify_email_from_address?.trim()
|
||||
const response = await authFetch(`${baseUrl}/admin/settings/test/email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(
|
||||
recipientEmail ? { recipient_email: recipientEmail } : {},
|
||||
),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Email test failed')
|
||||
}
|
||||
const data = await response.json()
|
||||
setSectionFeedback((current) => ({
|
||||
...current,
|
||||
[sectionGroup.key]: {
|
||||
tone: 'status',
|
||||
message: `Test email sent to ${data?.recipient_email ?? 'the configured mailbox'}.`,
|
||||
},
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const serviceKey = SERVICE_TEST_ENDPOINTS[sectionGroup.key]
|
||||
if (!serviceKey) {
|
||||
return
|
||||
}
|
||||
const response = await authFetch(`${baseUrl}/status/services/${serviceKey}/test`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || 'Connection test failed')
|
||||
}
|
||||
const data = await response.json()
|
||||
setSectionFeedback((current) => ({
|
||||
...current,
|
||||
[sectionGroup.key]: formatServiceTestFeedback(data),
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setSectionFeedback((current) => ({
|
||||
...current,
|
||||
[sectionGroup.key]: {
|
||||
tone: 'error',
|
||||
message: parseActionError(err, 'Could not run test.'),
|
||||
},
|
||||
}))
|
||||
} finally {
|
||||
setSectionTesting((current) => ({ ...current, [sectionGroup.key]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1316,6 +1492,37 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
? 'Saved requests only'
|
||||
: 'Saved requests only'
|
||||
const cacheTtlLabel = formValues.requests_sync_ttl_minutes || '60'
|
||||
const maintenanceRail = showMaintenance ? (
|
||||
<div className="admin-rail-stack">
|
||||
<div className="admin-rail-card">
|
||||
<span className="admin-rail-eyebrow">Maintenance</span>
|
||||
<h2>Admin tools</h2>
|
||||
<p>Repair, cleanup, diagnostics, and nuclear resync are grouped into a single operating page.</p>
|
||||
</div>
|
||||
<div className="admin-rail-card cache-rail-card">
|
||||
<span className="admin-rail-eyebrow">Runtime</span>
|
||||
<h2>Service state</h2>
|
||||
<div className="cache-rail-metrics">
|
||||
<div className="cache-rail-metric">
|
||||
<span>Maintenance job</span>
|
||||
<strong>{maintenanceBusy ? 'Running' : 'Idle'}</strong>
|
||||
</div>
|
||||
<div className="cache-rail-metric">
|
||||
<span>Live updates</span>
|
||||
<strong>{liveStreamConnected ? 'Connected' : 'Polling'}</strong>
|
||||
</div>
|
||||
<div className="cache-rail-metric">
|
||||
<span>Log lines in view</span>
|
||||
<strong>{logsLines.length}</strong>
|
||||
</div>
|
||||
<div className="cache-rail-metric">
|
||||
<span>Last tool status</span>
|
||||
<strong>{maintenanceStatus || 'Idle'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
const cacheRail = showCacheExtras ? (
|
||||
<div className="admin-rail-stack">
|
||||
<div className="admin-rail-card cache-rail-card">
|
||||
@@ -1397,15 +1604,16 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
<AdminShell
|
||||
title={SECTION_LABELS[section] ?? 'Settings'}
|
||||
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
||||
rail={cacheRail}
|
||||
rail={maintenanceRail ?? cacheRail}
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/admin')}>
|
||||
Back to settings
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{status && <div className="error-banner">{status}</div>}
|
||||
{settingsSections.length > 0 ? (
|
||||
<form onSubmit={submit} className="admin-form">
|
||||
<div className="admin-form">
|
||||
{settingsSections
|
||||
.filter(shouldRenderSection)
|
||||
.map((sectionGroup) => (
|
||||
@@ -1743,6 +1951,36 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (
|
||||
setting.key === 'log_http_client_level' ||
|
||||
setting.key === 'log_background_sync_level'
|
||||
) {
|
||||
return (
|
||||
<label key={setting.key} data-helper={helperText || undefined}>
|
||||
<span className="label-row">
|
||||
<span>{labelFromKey(setting.key)}</span>
|
||||
<span className="meta">
|
||||
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
name={setting.key}
|
||||
value={value || 'INFO'}
|
||||
onChange={(event) =>
|
||||
setFormValues((current) => ({
|
||||
...current,
|
||||
[setting.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="DEBUG">DEBUG</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (setting.key === 'artwork_cache_mode') {
|
||||
return (
|
||||
<label key={setting.key} data-helper={helperText || undefined}>
|
||||
@@ -1965,13 +2203,52 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{sectionFeedback[sectionGroup.key] && (
|
||||
<div
|
||||
className={
|
||||
sectionFeedback[sectionGroup.key]?.tone === 'error'
|
||||
? 'error-banner'
|
||||
: 'status-banner'
|
||||
}
|
||||
>
|
||||
{sectionFeedback[sectionGroup.key]?.message}
|
||||
</div>
|
||||
)}
|
||||
<div className="settings-section-actions">
|
||||
{sectionGroup.key === 'magent-notify-email' ? (
|
||||
<label className="settings-inline-field">
|
||||
<span>Test email recipient</span>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Leave blank to use the configured sender"
|
||||
value={emailTestRecipient}
|
||||
onChange={(event) => setEmailTestRecipient(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void saveSettingGroup(sectionGroup)}
|
||||
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
|
||||
>
|
||||
{sectionSaving[sectionGroup.key] ? 'Saving...' : 'Save section'}
|
||||
</button>
|
||||
{getSectionTestLabel(sectionGroup.key) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-button"
|
||||
onClick={() => void testSettingGroup(sectionGroup)}
|
||||
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
|
||||
>
|
||||
{sectionTesting[sectionGroup.key]
|
||||
? 'Testing...'
|
||||
: getSectionTestLabel(sectionGroup.key)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{status && <div className="status-banner">{status}</div>}
|
||||
<div className="admin-actions">
|
||||
<button type="submit">Save changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="status-banner">
|
||||
{section === 'magent'
|
||||
@@ -2039,28 +2316,65 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
<div className="section-header">
|
||||
<h2>Maintenance</h2>
|
||||
</div>
|
||||
<div className="status-banner">
|
||||
Emergency tools. Use with care: flush + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Seerr users/requests.
|
||||
</div>
|
||||
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
|
||||
<div className="maintenance-grid">
|
||||
<button type="button" onClick={runRepair}>
|
||||
Repair database
|
||||
</button>
|
||||
<button type="button" className="ghost-button" onClick={runCleanup}>
|
||||
Clean history (older than 90 days)
|
||||
</button>
|
||||
<button type="button" className="ghost-button" onClick={clearLogFile}>
|
||||
Clear activity log
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
onClick={runFlushAndResync}
|
||||
disabled={maintenanceBusy}
|
||||
>
|
||||
Nuclear flush + resync
|
||||
</button>
|
||||
<div className="maintenance-layout">
|
||||
<div className="admin-panel maintenance-tools-panel">
|
||||
<div className="maintenance-panel-copy">
|
||||
<h3>Recovery and cleanup</h3>
|
||||
<p className="lede">
|
||||
Run repair, cleanup, logging, and full reset actions from one place. Nuclear flush
|
||||
wipes non-admin users, invite links, profiles, cached requests, and history before
|
||||
re-syncing Seerr users and requests.
|
||||
</p>
|
||||
</div>
|
||||
<div className="status-banner">
|
||||
Emergency tools. Use with care, especially on live data.
|
||||
</div>
|
||||
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
|
||||
<div className="maintenance-action-grid">
|
||||
<div className="maintenance-action-card">
|
||||
<div className="maintenance-action-copy">
|
||||
<h3>Repair database</h3>
|
||||
<p>Run integrity and repair routines against the local Magent database.</p>
|
||||
</div>
|
||||
<button type="button" onClick={runRepair}>
|
||||
Repair database
|
||||
</button>
|
||||
</div>
|
||||
<div className="maintenance-action-card">
|
||||
<div className="maintenance-action-copy">
|
||||
<h3>Clean request history</h3>
|
||||
<p>Remove request history entries older than 90 days.</p>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" onClick={runCleanup}>
|
||||
Clean history
|
||||
</button>
|
||||
</div>
|
||||
<div className="maintenance-action-card">
|
||||
<div className="maintenance-action-copy">
|
||||
<h3>Clear activity log</h3>
|
||||
<p>Truncate the local activity log file so fresh troubleshooting starts clean.</p>
|
||||
</div>
|
||||
<button type="button" className="ghost-button" onClick={clearLogFile}>
|
||||
Clear activity log
|
||||
</button>
|
||||
</div>
|
||||
<div className="maintenance-action-card maintenance-action-card-danger">
|
||||
<div className="maintenance-action-copy">
|
||||
<h3>Nuclear flush + resync</h3>
|
||||
<p>Wipe non-admin user and request objects, then rebuild from Seerr.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="danger-button"
|
||||
onClick={runFlushAndResync}
|
||||
disabled={maintenanceBusy}
|
||||
>
|
||||
{maintenanceBusy ? 'Running...' : 'Nuclear flush + resync'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AdminDiagnosticsPanel embedded />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
27
frontend/app/admin/diagnostics/page.tsx
Normal file
27
frontend/app/admin/diagnostics/page.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import AdminShell from '../../ui/AdminShell'
|
||||
import AdminDiagnosticsPanel from '../../ui/AdminDiagnosticsPanel'
|
||||
|
||||
export default function AdminDiagnosticsPage() {
|
||||
return (
|
||||
<AdminShell
|
||||
title="Diagnostics"
|
||||
subtitle="Run connectivity, delivery, and platform health checks for every configured dependency."
|
||||
rail={
|
||||
<div className="admin-rail-stack">
|
||||
<div className="admin-rail-card">
|
||||
<span className="admin-rail-eyebrow">Diagnostics</span>
|
||||
<h2>Shared console</h2>
|
||||
<p>
|
||||
This page and Maintenance now use the same diagnostics panel, so every test target and
|
||||
notification ping stays in one source of truth.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AdminDiagnosticsPanel />
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
@@ -1537,6 +1537,29 @@ button span {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.settings-section-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.settings-inline-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: min(100%, 320px);
|
||||
flex: 1 1 320px;
|
||||
}
|
||||
|
||||
.settings-inline-field span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@@ -1631,6 +1654,61 @@ button span {
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
}
|
||||
|
||||
.maintenance-layout {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.maintenance-tools-panel {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.maintenance-panel-copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.maintenance-action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.maintenance-action-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
padding: 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
}
|
||||
|
||||
.maintenance-action-card button {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.maintenance-action-copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.maintenance-action-copy h3 {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.maintenance-action-copy p {
|
||||
color: var(--ink-muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.maintenance-action-card-danger {
|
||||
border-color: rgba(255, 107, 43, 0.24);
|
||||
background: rgba(255, 107, 43, 0.05);
|
||||
}
|
||||
|
||||
.schedule-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -5689,3 +5767,306 @@ textarea {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.diagnostics-page {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.diagnostics-header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.diagnostics-control-panel {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.diagnostics-control-copy {
|
||||
max-width: 52rem;
|
||||
}
|
||||
|
||||
.diagnostics-control-actions {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.diagnostics-email-recipient {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
min-width: min(100%, 20rem);
|
||||
flex: 1 1 20rem;
|
||||
}
|
||||
|
||||
.diagnostics-email-recipient span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.07em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.diagnostics-inline-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.diagnostics-inline-metric {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
padding: 0.85rem 0.95rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.diagnostics-inline-metric span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.76rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.diagnostics-inline-metric strong {
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.diagnostics-inline-last-run {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--ink-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.diagnostics-control-actions .is-active {
|
||||
border-color: rgba(92, 141, 255, 0.44);
|
||||
background: rgba(92, 141, 255, 0.12);
|
||||
}
|
||||
|
||||
.diagnostics-error {
|
||||
color: #ffb4b4;
|
||||
border-color: rgba(255, 118, 118, 0.32);
|
||||
background: rgba(96, 20, 20, 0.28);
|
||||
}
|
||||
|
||||
.diagnostics-category-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.diagnostics-category-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.diagnostics-category-header h2 {
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.diagnostics-category-header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.diagnostics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.diagnostic-card {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.02)),
|
||||
rgba(8, 12, 20, 0.82);
|
||||
}
|
||||
|
||||
.diagnostic-card-up {
|
||||
border-color: rgba(78, 201, 140, 0.28);
|
||||
}
|
||||
|
||||
.diagnostic-card-down {
|
||||
border-color: rgba(255, 116, 116, 0.26);
|
||||
}
|
||||
|
||||
.diagnostic-card-degraded {
|
||||
border-color: rgba(255, 194, 99, 0.24);
|
||||
}
|
||||
|
||||
.diagnostic-card-disabled,
|
||||
.diagnostic-card-not_configured {
|
||||
border-color: rgba(161, 173, 192, 0.2);
|
||||
}
|
||||
|
||||
.diagnostic-card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.diagnostic-card-copy {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.diagnostic-card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.diagnostic-card-title-row h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.diagnostic-card-copy p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.diagnostic-meta-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.diagnostic-meta-item {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
min-width: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.85rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.diagnostic-meta-item span {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.diagnostic-meta-item strong {
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.diagnostic-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7rem;
|
||||
min-height: 2.8rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.diagnostic-message-up {
|
||||
background: rgba(40, 95, 66, 0.22);
|
||||
}
|
||||
|
||||
.diagnostic-message-down {
|
||||
background: rgba(105, 33, 33, 0.24);
|
||||
}
|
||||
|
||||
.diagnostic-message-degraded {
|
||||
background: rgba(115, 82, 27, 0.2);
|
||||
}
|
||||
|
||||
.diagnostic-message-disabled,
|
||||
.diagnostic-message-not_configured,
|
||||
.diagnostic-message-idle {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.diagnostics-rail-metrics {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.diagnostics-rail-metric {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.diagnostics-rail-metric span {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.diagnostics-rail-metric strong {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.diagnostics-rail-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.diagnostics-rail-last-run {
|
||||
margin: 0.85rem 0 0;
|
||||
}
|
||||
|
||||
.small-pill.is-positive {
|
||||
border-color: rgba(78, 201, 140, 0.34);
|
||||
color: rgba(206, 255, 227, 0.92);
|
||||
background: rgba(31, 92, 62, 0.22);
|
||||
}
|
||||
|
||||
.system-pill-idle,
|
||||
.system-pill-not_configured,
|
||||
.system-pill-disabled {
|
||||
color: rgba(224, 230, 239, 0.84);
|
||||
background: rgba(129, 141, 158, 0.18);
|
||||
border-color: rgba(129, 141, 158, 0.26);
|
||||
}
|
||||
|
||||
.system-disabled .system-dot {
|
||||
background: rgba(151, 164, 184, 0.76);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.diagnostics-control-panel,
|
||||
.diagnostic-card-top,
|
||||
.diagnostics-category-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.settings-section-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.settings-section-actions > * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.diagnostic-meta-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
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