Finalize diagnostics, logging controls, and email test support

This commit is contained in:
2026-03-01 22:34:07 +13:00
parent 12d3777e76
commit d1c9acbb8d
19 changed files with 2578 additions and 99 deletions

View File

@@ -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>
)}

View 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>
)
}