Files
Magent/frontend/app/admin/SettingsPage.tsx
T
Assclaw b5e4c57e93
Magent CI/CD / verify (push) Successful in 11m34s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 17s
Simplify settings workspace and fix responsive admin controls
2026-09-06 17:42:28 +12:00

2114 lines
81 KiB
TypeScript

'use client'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { authFetch, clearToken, getApiBase, getEventStreamToken, getToken } from '../lib/auth'
import SettingField from './SettingField'
import SettingsRegion from './SettingsRegion'
import { serviceStatusLabel } from './configNavigation'
import AdminShell from '../ui/AdminShell'
type AdminSetting = {
key: string
value: string | null
isSet: boolean
source: string
sensitive: boolean
}
type ServiceOptions = {
rootFolders: { id: number; path: string; label: string }[]
qualityProfiles: { id: number; name: string; label: string }[]
}
type ServiceStatus = {
name: string
status: string
message?: string
}
const SECTION_LABELS: Record<string, string> = {
magent: 'Magent',
general: 'Hosting & proxy',
notifications: 'Email & notifications',
seerr: 'Seerr',
jellyseerr: 'Seerr',
jellyfin: 'Jellyfin',
artwork: 'Artwork cache',
cache: 'Request cache',
sonarr: 'Sonarr',
radarr: 'Radarr',
bazarr: 'Bazarr',
prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent',
logs: 'Activity log',
maintenance: 'Recovery & cleanup',
requests: 'Request updates',
'issue-workflow': 'Issue follow-up',
site: 'Site & sign-in',
}
const BOOL_SETTINGS = new Set([
'jellyfin_sync_to_arr',
'site_banner_enabled',
'site_login_show_jellyfin_login',
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
'site_nav_show_requests',
'magent_proxy_enabled',
'magent_proxy_trust_forwarded_headers',
'magent_ssl_bind_enabled',
'magent_notify_enabled',
'magent_notify_email_enabled',
'magent_notify_email_use_tls',
'magent_notify_email_use_ssl',
'magent_notify_discord_enabled',
'magent_notify_telegram_enabled',
'magent_notify_push_enabled',
'magent_notify_webhook_enabled',
])
const TEXTAREA_SETTINGS = new Set([
'site_banner_message',
'site_changelog',
'magent_ssl_certificate_pem',
'magent_ssl_private_key_pem',
])
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',
'requests_cleanup_days',
'issue_confirmation_contact_attempts',
'issue_confirmation_interval_value',
])
const SECTION_DESCRIPTIONS: Record<string, string> = {
magent:
'Magent service settings. Runtime and notification controls are organized under General and Notifications.',
general:
'Public addresses and advanced deployment settings.',
notifications:
'Choose how Magent sends invitations, password resets and issue updates.',
seerr: 'Connect Seerr where users submit content requests.',
jellyseerr: 'Connect Seerr where users submit content requests.',
jellyfin: 'Jellyfin connection, public playback links, user sync, and availability checks.',
artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'Sonarr connection and the default profile and library location for TV requests.',
radarr: 'Radarr connection and the default profile and library location for movie requests.',
bazarr: 'Bazarr connection used to find and replace movie and episode subtitles.',
prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
requests: 'Control how often requests are refreshed and cleaned up.',
'issue-workflow': 'Control reporter confirmation, reminder timing, and automatic issue closure.',
logs: 'Control log output and inspect recent application activity for troubleshooting.',
maintenance: 'Repair cached data, clean historical records, and run recovery operations.',
site: 'Announcements and sign-in options for your users.',
}
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
magent: 'magent',
general: 'magent',
notifications: 'magent',
seerr: 'jellyseerr',
jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin',
artwork: null,
sonarr: 'sonarr',
radarr: 'radarr',
bazarr: 'bazarr',
prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent',
requests: 'requests',
'issue-workflow': 'issue',
cache: null,
logs: 'log',
maintenance: null,
site: 'site',
}
const MAGENT_SECTION_GROUPS: Array<{
key: string
title: string
description: string
keys: string[]
}> = [
{
key: 'magent-runtime',
title: 'Public addresses',
description:
'Addresses used in links from Magent.',
keys: [
'magent_application_url',
'magent_api_url',
],
},
{
key: 'magent-binding',
title: 'Deployment ports',
description: 'Stored preferences do not change Docker port mappings. Update the deployment and restart to change listening ports.',
keys: ['magent_application_port', 'magent_api_port', 'magent_bind_host'],
},
{
key: 'magent-proxy',
title: 'Proxy',
description:
'Reverse proxy awareness and base URL handling when Magent sits behind Caddy/NGINX/Traefik.',
keys: [
'magent_proxy_enabled',
'magent_proxy_base_url',
'magent_proxy_trust_forwarded_headers',
'magent_proxy_forwarded_prefix',
],
},
{
key: 'magent-ssl',
title: 'Direct TLS settings',
description:
'For direct hosting only. The beta deployment terminates HTTPS at its reverse proxy.',
keys: [
'magent_ssl_bind_enabled',
'magent_ssl_certificate_path',
'magent_ssl_private_key_path',
'magent_ssl_certificate_pem',
'magent_ssl_private_key_pem',
],
},
{
key: 'magent-notify-core',
title: 'Notifications',
description:
'Global notification controls and provider-independent defaults used by Magent messaging features.',
keys: ['magent_notify_enabled'],
},
{
key: 'magent-notify-email',
title: 'Email',
description: 'SMTP configuration for email notifications.',
keys: [
'magent_notify_email_enabled',
'magent_notify_email_smtp_host',
'magent_notify_email_smtp_port',
'magent_notify_email_smtp_username',
'magent_notify_email_smtp_password',
'magent_notify_email_from_address',
'magent_notify_email_from_name',
'magent_notify_email_use_tls',
'magent_notify_email_use_ssl',
],
},
{
key: 'magent-notify-discord',
title: 'Discord',
description: 'Webhook settings for Discord notifications and feedback routing.',
keys: ['magent_notify_discord_enabled', 'magent_notify_discord_webhook_url'],
},
{
key: 'magent-notify-telegram',
title: 'Telegram',
description: 'Bot token and chat target for Telegram notifications.',
keys: [
'magent_notify_telegram_enabled',
'magent_notify_telegram_bot_token',
'magent_notify_telegram_chat_id',
],
},
{
key: 'magent-notify-push',
title: 'Push / Mobile',
description:
'Generic push messaging configuration (ntfy, Gotify, Pushover, webhook-style push endpoints).',
keys: [
'magent_notify_push_enabled',
'magent_notify_push_provider',
'magent_notify_push_base_url',
'magent_notify_push_topic',
'magent_notify_push_token',
'magent_notify_push_user_key',
'magent_notify_push_device',
],
},
{
key: 'magent-notify-webhook',
title: 'Generic Webhook',
description: 'Send notifications to a custom automation or integration endpoint.',
keys: ['magent_notify_webhook_enabled', 'magent_notify_webhook_url'],
},
]
const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
general: new Set(['magent-runtime', 'magent-binding', 'magent-proxy', 'magent-ssl']),
notifications: new Set([
'magent-notify-core',
'magent-notify-email',
'magent-notify-discord',
'magent-notify-telegram',
'magent-notify-push',
'magent-notify-webhook',
]),
}
const SITE_SECTION_GROUPS: Array<{
key: string
title: string
description: string
keys: string[]
}> = [
{
key: 'site-banner',
title: 'Site Banner',
description: 'Control the sitewide banner message, tone, and visibility.',
keys: ['site_banner_enabled', 'site_banner_tone', 'site_banner_message'],
},
{
key: 'site-login',
title: 'Login Page Behaviour',
description: 'Control which sign-in and recovery options are shown on the logged-out login page.',
keys: [
'site_login_show_jellyfin_login',
'site_login_show_local_login',
'site_login_show_forgot_password',
'site_login_show_signup_link',
],
},
{
key: 'site-navigation',
title: 'Navigation',
description: 'Control new requests in the navigation.',
keys: ['site_nav_show_requests'],
},
]
const STANDARD_SECTION_GROUPS: Record<
string,
Array<{ key: string; title: string; description: string; keys: string[] }>
> = {
seerr: [
{
key: 'seerr-connection',
title: 'Connection',
description: 'The Seerr endpoint and API credential Magent uses for request discovery and status.',
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
},
],
jellyseerr: [
{
key: 'seerr-connection',
title: 'Connection',
description: 'The Seerr endpoint and API credential Magent uses for request discovery and status.',
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
},
],
jellyfin: [
{
key: 'jellyfin-connection',
title: 'Connection',
description: 'Internal Jellyfin endpoint and administrator API credential used for lookups and user sync.',
keys: ['jellyfin_base_url', 'jellyfin_api_key'],
},
{
key: 'jellyfin-playback',
title: 'Playback Links',
description: 'Public address used when a viewer opens an available title from Magent.',
keys: ['jellyfin_public_url'],
},
{
key: 'jellyfin-users',
title: 'Library and User Sync',
description: 'Control cross-service library reconciliation and manually import Jellyfin users.',
keys: ['jellyfin_sync_to_arr'],
},
],
sonarr: [
{
key: 'sonarr-connection',
title: 'Connection',
description: 'Sonarr endpoint and API credential used for TV collection operations.',
keys: ['sonarr_base_url', 'sonarr_api_key'],
},
{
key: 'sonarr-library',
title: 'TV Collection Defaults',
description: 'Default quality profile and destination folder used for TV requests.',
keys: ['sonarr_quality_profile_id', 'sonarr_root_folder'],
},
],
radarr: [
{
key: 'radarr-connection',
title: 'Connection',
description: 'Radarr endpoint and API credential used for movie collection operations.',
keys: ['radarr_base_url', 'radarr_api_key'],
},
{
key: 'radarr-library',
title: 'Movie Collection Defaults',
description: 'Default quality profile and destination folder used for movie requests.',
keys: ['radarr_quality_profile_id', 'radarr_root_folder'],
},
],
bazarr: [
{
key: 'bazarr-connection',
title: 'Connection',
description: 'Bazarr endpoint, API credential, and default language used by subtitle issue repairs.',
keys: ['bazarr_base_url', 'bazarr_api_key', 'bazarr_default_language'],
},
],
prowlarr: [
{
key: 'prowlarr-connection',
title: 'Connection',
description: 'Prowlarr endpoint and API credential used for indexer health and release discovery.',
keys: ['prowlarr_base_url', 'prowlarr_api_key'],
},
],
qbittorrent: [
{
key: 'qbittorrent-connection',
title: 'Connection and Sign-in',
description: 'qBittorrent Web UI endpoint and credentials used for live download progress and recovery.',
keys: ['qbittorrent_base_url', 'qbittorrent_username', 'qbittorrent_password'],
},
],
requests: [
{
key: 'requests-advanced',
title: 'Advanced scheduling',
description: 'How frequently the background worker checks whether a full refresh is due.',
keys: ['requests_poll_interval_seconds'],
},
{
key: 'requests-sync',
title: 'Synchronization Schedule',
description: 'Control incremental checks and the scheduled full request-cache rebuild.',
keys: [
'requests_delta_sync_interval_minutes',
'requests_full_sync_time',
],
},
{
key: 'requests-retention',
title: 'History Retention',
description: 'Choose when old status history is cleaned up and how long it is retained.',
keys: ['requests_cleanup_time', 'requests_cleanup_days'],
},
],
'issue-workflow': [
{
key: 'issues-resolution-confirmation',
title: 'Resolution confirmation',
description: 'Choose how often Magent asks a reporter to confirm a fix before the issue is closed automatically.',
keys: [
'issue_confirmation_contact_attempts',
'issue_confirmation_interval_value',
'issue_confirmation_interval_unit',
],
},
],
logs: [
{
key: 'logs-output',
title: 'Log Output',
description: 'Set the default application verbosity and the active log-file destination.',
keys: ['log_level', 'log_file'],
},
{
key: 'logs-rotation',
title: 'File Rotation',
description: 'Limit log-file growth and choose how many historical files remain on disk.',
keys: ['log_file_max_bytes', 'log_file_backup_count'],
},
{
key: 'logs-components',
title: 'Component Verbosity',
description: 'Tune noisy outbound-service and scheduled-background messages independently.',
keys: ['log_http_client_level', 'log_background_sync_level'],
},
],
}
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
bazarr_base_url: 'Bazarr base URL',
bazarr_api_key: 'Bazarr API key',
bazarr_default_language: 'Default subtitle language',
issue_confirmation_contact_attempts: 'Confirmation emails before auto-close',
issue_confirmation_interval_value: 'Confirmation interval',
issue_confirmation_interval_unit: 'Interval unit',
jellyseerr_base_url: 'Seerr base URL',
jellyseerr_api_key: 'Seerr API key',
magent_application_url: 'Application URL',
magent_application_port: 'Application port',
magent_api_url: 'API URL',
magent_api_port: 'API port',
magent_bind_host: 'Bind host',
magent_proxy_enabled: 'Proxy support enabled',
magent_proxy_base_url: 'Proxy base URL',
magent_proxy_trust_forwarded_headers: 'Trust forwarded headers',
magent_proxy_forwarded_prefix: 'Forwarded path prefix',
magent_ssl_bind_enabled: 'Manual SSL bind enabled',
magent_ssl_certificate_path: 'Certificate path',
magent_ssl_private_key_path: 'Private key path',
magent_ssl_certificate_pem: 'Certificate (PEM)',
magent_ssl_private_key_pem: 'Private key (PEM)',
magent_notify_enabled: 'Notifications enabled',
magent_notify_email_enabled: 'Email notifications enabled',
magent_notify_email_smtp_host: 'SMTP host',
magent_notify_email_smtp_port: 'SMTP port',
magent_notify_email_smtp_username: 'SMTP username',
magent_notify_email_smtp_password: 'SMTP password',
magent_notify_email_from_address: 'From email address',
magent_notify_email_from_name: 'From display name',
magent_notify_email_use_tls: 'Use STARTTLS',
magent_notify_email_use_ssl: 'Use SSL/TLS (implicit)',
magent_notify_discord_enabled: 'Discord notifications enabled',
magent_notify_discord_webhook_url: 'Discord webhook URL',
magent_notify_telegram_enabled: 'Telegram notifications enabled',
magent_notify_telegram_bot_token: 'Telegram bot token',
magent_notify_telegram_chat_id: 'Telegram chat ID',
magent_notify_push_enabled: 'Push notifications enabled',
magent_notify_push_provider: 'Push provider',
magent_notify_push_base_url: 'Push provider/base URL',
magent_notify_push_topic: 'Topic / channel',
magent_notify_push_token: 'API token / password',
magent_notify_push_user_key: 'User key / recipient key',
magent_notify_push_device: 'Device / target',
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
magent_notify_webhook_url: 'Generic webhook URL',
jellyfin_base_url: 'Internal server URL',
jellyfin_api_key: 'Administrator API key',
jellyfin_public_url: 'Public playback URL',
jellyfin_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr',
sonarr_base_url: 'Sonarr server URL',
sonarr_api_key: 'Sonarr API key',
sonarr_quality_profile_id: 'Default TV quality profile',
sonarr_root_folder: 'Default TV root folder',
radarr_base_url: 'Radarr server URL',
radarr_api_key: 'Radarr API key',
radarr_quality_profile_id: 'Default movie quality profile',
radarr_root_folder: 'Default movie root folder',
prowlarr_base_url: 'Prowlarr server URL',
prowlarr_api_key: 'Prowlarr API key',
qbittorrent_base_url: 'Web UI URL',
qbittorrent_username: 'Web UI username',
qbittorrent_password: 'Web UI password',
requests_sync_ttl_minutes: 'Request cache freshness (minutes)',
requests_poll_interval_seconds: 'Full-sync eligibility check (seconds)',
requests_delta_sync_interval_minutes: 'Recent-change sync interval (minutes)',
requests_full_sync_time: 'Daily full-sync time',
requests_cleanup_time: 'Daily history cleanup time',
requests_cleanup_days: 'History retention (days)',
requests_data_source: 'Request read source',
artwork_cache_mode: 'Artwork delivery mode',
log_level: 'Application log level',
log_file: 'Active log file',
site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
site_login_show_local_login: 'Login page: local Magent sign-in',
site_login_show_forgot_password: 'Login page: forgot password',
site_login_show_signup_link: 'Login page: invite signup link',
site_nav_show_requests: 'Top navigation: New Requests',
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) =>
SETTING_LABEL_OVERRIDES[key] ??
key
.replaceAll('_', ' ')
.replace('jellyseerr', 'Seerr')
.replace('base url', 'URL')
.replace('api key', 'API key')
.replace('quality profile id', 'Quality profile ID')
.replace('root folder', 'Root folder')
.replace('qbittorrent', 'qBittorrent')
.replace('requests sync ttl minutes', 'Saved request refresh TTL (minutes)')
.replace('requests poll interval seconds', 'Full refresh check interval (seconds)')
.replace('requests delta sync interval minutes', 'Delta sync interval (minutes)')
.replace('requests full sync time', 'Daily full refresh time (24h)')
.replace('requests cleanup time', 'Daily history cleanup time (24h)')
.replace('requests cleanup days', 'History retention window (days)')
.replace('requests data source', 'Request source (cache vs Seerr)')
.replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode')
.replace('site build number', 'Build number')
.replace('site banner enabled', 'Sitewide banner enabled')
.replace('site banner message', 'Sitewide banner message')
.replace('site banner tone', 'Sitewide banner tone')
.replace('site nav show requests', 'Top navigation: New Requests')
.replace('site changelog', 'Changelog text')
const formatBytes = (value?: number | null) => {
if (!value || value <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
const decimals = unitIndex === 0 || size >= 10 ? 0 : 1
return `${size.toFixed(decimals)} ${units[unitIndex]}`
}
type SettingsPageProps = {
section: string
}
type SettingsSectionGroup = {
key: string
title: string
items: AdminSetting[]
description?: string
}
type SectionFeedback = {
tone: 'status' | 'error'
message: string
}
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
'seerr-connection': 'seerr',
'jellyfin-connection': 'jellyfin',
'sonarr-connection': 'sonarr',
'radarr-connection': 'radarr',
'bazarr-connection': 'bazarr',
'prowlarr-connection': 'prowlarr',
'qbittorrent-connection': '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)
const [sonarrError, setSonarrError] = useState<string | null>(null)
const [radarrError, setRadarrError] = useState<string | null>(null)
const [jellyfinSyncStatus, setJellyfinSyncStatus] = useState<string | null>(null)
const [requestsSyncStatus, setRequestsSyncStatus] = useState<string | null>(null)
const [artworkPrefetchStatus, setArtworkPrefetchStatus] = useState<string | null>(null)
const [logsStatus, setLogsStatus] = useState<string | null>(null)
const [logsLines, setLogsLines] = useState<string[]>([])
const [logsCount, setLogsCount] = useState(200)
const [cacheRows, setCacheRows] = useState<any[]>([])
const [cacheCount, setCacheCount] = useState(50)
const [cacheStatus, setCacheStatus] = useState<string | null>(null)
const [cacheLoading, setCacheLoading] = useState(false)
const [requestsSync, setRequestsSync] = useState<any | null>(null)
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
const [artworkSummary, setArtworkSummary] = useState<any | null>(null)
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([])
const [serviceStatusCheckedAt, setServiceStatusCheckedAt] = useState<string | null>(null)
const requestsSyncRef = useRef<any | null>(null)
const artworkPrefetchRef = useRef<any | null>(null)
const computeProgressPercent = (
completedValue: unknown,
totalValue: unknown,
statusValue: unknown
): number => {
if (String(statusValue).toLowerCase() === 'completed') {
return 100
}
const completed = Number(completedValue)
const total = Number(totalValue)
if (!Number.isFinite(completed) || !Number.isFinite(total) || total <= 0 || completed <= 0) {
return 0
}
return Math.max(0, Math.min(100, Math.round((completed / total) * 100)))
}
const loadSettings = useCallback(async (refreshedKeys?: Set<string>) => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/settings`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error('Failed to load settings')
}
const data = await response.json()
const fetched: AdminSetting[] = Array.isArray(data?.settings)
? data.settings.map((item: AdminSetting) => ({ ...item, value: item.value == null ? null : String(item.value) }))
: []
setSettings(fetched)
const initialValues: Record<string, string> = {}
for (const setting of fetched) {
if (!setting.sensitive && setting.value) {
if (BOOL_SETTINGS.has(setting.key)) {
initialValues[setting.key] = String(setting.value).toLowerCase()
} else {
initialValues[setting.key] = setting.value
}
} else {
initialValues[setting.key] = ''
}
}
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])
const loadArtworkPrefetchStatus = useCallback(async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`)
if (!response.ok) {
return
}
const data = await response.json()
setArtworkPrefetch(data?.prefetch ?? null)
} catch (err) {
console.error(err)
}
}, [])
const loadArtworkSummary = useCallback(async () => {
setArtworkSummaryStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/summary`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Artwork summary fetch failed')
}
const data = await response.json()
setArtworkSummary(data?.summary ?? null)
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not load artwork stats.'
setArtworkSummaryStatus(message)
}
}, [])
const loadOptions = useCallback(async (service: 'sonarr' | 'radarr') => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/${service}/options`)
if (!response.ok) {
throw new Error('Options unavailable')
}
const data = await response.json()
if (service === 'sonarr') {
setSonarrOptions({
rootFolders: Array.isArray(data?.rootFolders) ? data.rootFolders : [],
qualityProfiles: Array.isArray(data?.qualityProfiles) ? data.qualityProfiles : [],
})
setSonarrError(null)
} else {
setRadarrOptions({
rootFolders: Array.isArray(data?.rootFolders) ? data.rootFolders : [],
qualityProfiles: Array.isArray(data?.qualityProfiles) ? data.qualityProfiles : [],
})
setRadarrError(null)
}
} catch (err) {
console.error(err)
if (service === 'sonarr') {
setSonarrError('Could not load Sonarr options.')
} else {
setRadarrError('Could not load Radarr options.')
}
}
}, [])
const loadServiceStatuses = useCallback(async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services`)
if (!response.ok) {
return
}
const data = await response.json()
setServiceStatuses(Array.isArray(data?.services) ? data.services : [])
setServiceStatusCheckedAt(new Date().toISOString())
} catch (err) {
console.error(err)
}
}, [])
useEffect(() => {
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
try {
await Promise.all([loadSettings(), loadServiceStatuses()])
if (section === 'cache' || section === 'artwork') {
await loadArtworkPrefetchStatus()
await loadArtworkSummary()
}
} catch (err) {
console.error(err)
setStatus('Could not load admin settings.')
} finally {
setLoading(false)
}
}
load()
if (section === 'sonarr') {
void loadOptions('sonarr')
}
if (section === 'radarr') {
void loadOptions('radarr')
}
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadServiceStatuses, loadSettings, router, section])
const groupedSettings = useMemo(() => {
const groups: Record<string, AdminSetting[]> = {}
for (const setting of settings) {
const section = setting.key.split('_')[0] ?? 'other'
if (!groups[section]) groups[section] = []
groups[section].push(setting)
}
return groups
}, [settings])
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
const statusNamesBySection: Record<string, string[]> = {
seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
jellyfin: ['Jellyfin'],
sonarr: ['Sonarr'],
radarr: ['Radarr'],
bazarr: ['Bazarr'],
prowlarr: ['Prowlarr'],
qbittorrent: ['qBittorrent', 'Qbittorrent'],
}
const statusNames = statusNamesBySection[section] ?? statusNamesBySection[settingsSection ?? ''] ?? []
const currentServiceStatus = serviceStatuses.find((service) =>
statusNames.some((name) => name.toLowerCase() === service.name.toLowerCase())
)
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
const isSiteGroupedSection = section === 'site'
const isCacheSection = section === 'cache'
const isArtworkSection = section === 'artwork'
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
const artworkSettingKeys = new Set(['artwork_cache_mode'])
const generatedSettingKeys = new Set(['site_changelog', 'site_build_number'])
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
const obsoleteSettingKeys = new Set([
'sonarr_qbittorrent_category',
'radarr_qbittorrent_category',
])
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
const buildDefinedSections = (
definitions: Array<{ key: string; title: string; description: string; keys: string[] }>,
sourceItems: AdminSetting[],
includeUnassigned = true,
): SettingsSectionGroup[] => {
const byKey = new Map(sourceItems.map((item) => [item.key, item]))
const assignedKeys = new Set(definitions.flatMap((group) => group.keys))
const groups = definitions.map((group) => ({
key: group.key,
title: group.title,
description: group.description,
items: group.keys
.map((key) => byKey.get(key))
.filter((item): item is AdminSetting => Boolean(item)),
}))
if (includeUnassigned) {
const unassigned = sourceItems.filter((item) => !assignedKeys.has(item.key))
if (unassigned.length > 0) {
groups.push({
key: `${section}-additional`,
title: 'Additional Settings',
description: 'Settings returned by Magent that do not yet belong to a dedicated subsection.',
items: unassigned.sort((a, b) => a.key.localeCompare(b.key)),
})
}
}
return groups
}
const standardDefinitions = STANDARD_SECTION_GROUPS[section]
const standardItems = settingsSection
? (groupedSettings[settingsSection] ?? []).filter(
(setting) => !obsoleteSettingKeys.has(setting.key) && !hiddenSettingKeys.has(setting.key),
)
: []
const settingsSections: SettingsSectionGroup[] = isCacheSection
? [
{
key: 'cache',
title: 'Request Cache Strategy',
description: 'Choose where request pages read from and how long cached request records remain fresh.',
items: cacheSettings,
},
]
: isArtworkSection
? [
{
key: 'artwork',
title: 'Artwork Delivery and Storage',
description: 'Choose how posters and backdrops are delivered, then inspect or rebuild the local artwork cache.',
items: artworkSettings,
},
]
: isMagentGroupedSection
? (() => {
if (section === 'magent') {
return []
}
const magentItems = groupedSettings.magent ?? []
const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set<string>()
const definitions = MAGENT_SECTION_GROUPS.filter((group) => allowedGroupKeys.has(group.key))
return buildDefinedSections(definitions, magentItems, false)
})()
: isSiteGroupedSection
? buildDefinedSections(
SITE_SECTION_GROUPS,
(groupedSettings.site ?? []).filter((setting) => !hiddenSettingKeys.has(setting.key)),
)
: standardDefinitions
? buildDefinedSections(standardDefinitions, standardItems)
: []
const showLogs = section === 'logs'
const showMaintenance = section === 'maintenance'
const showRequestsExtras = section === 'requests'
const showArtworkExtras = section === 'artwork'
const showCacheExtras = section === 'cache'
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
if (sectionGroup.items && sectionGroup.items.length > 0) return true
if (showArtworkExtras && sectionGroup.key === 'artwork') return true
if (showCacheExtras && sectionGroup.key === 'cache') return true
if (showRequestsExtras && sectionGroup.key === 'requests-sync') return true
return false
}
const renderedSettingsSections = settingsSections.filter(shouldRenderSection).sort((a, b) => Number(a.key === 'requests-advanced') - Number(b.key === 'requests-advanced'))
useEffect(() => {
requestsSyncRef.current = requestsSync
}, [requestsSync])
useEffect(() => {
artworkPrefetchRef.current = artworkPrefetch
}, [artworkPrefetch])
const settingDescriptions: Record<string, string> = {
issue_confirmation_contact_attempts:
'Number of confirmation emails sent after an issue is marked fixed. Set 0 to send none and close immediately.',
issue_confirmation_interval_value:
'Amount of time between confirmation emails, and the final waiting period before automatic closure.',
issue_confirmation_interval_unit:
'Unit used for the confirmation interval: days, weeks, or months.',
magent_application_url:
'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
magent_application_port:
'Preferred frontend/UI port for local or direct-hosted deployments.',
magent_api_url:
'Canonical public URL for the Magent API when it differs from the app URL.',
magent_api_port: 'Preferred API port for local or direct-hosted deployments.',
magent_bind_host:
'Host/IP to bind the application services to when running without an external process manager.',
magent_proxy_enabled:
'Enable reverse-proxy-aware behavior and use proxy-specific URL settings.',
magent_proxy_base_url:
'Base URL Magent should use when it is published behind a proxy path or external proxy hostname.',
magent_proxy_trust_forwarded_headers:
'Trust X-Forwarded-* headers from your reverse proxy.',
magent_proxy_forwarded_prefix:
'Optional path prefix added by your proxy (example: /magent).',
magent_ssl_bind_enabled:
'Enable direct HTTPS binding in Magent (for environments not terminating TLS at a proxy).',
magent_ssl_certificate_path:
'Path to the TLS certificate file on disk (PEM).',
magent_ssl_private_key_path:
'Path to the TLS private key file on disk (PEM).',
magent_ssl_certificate_pem:
'Paste the TLS certificate PEM if you want Magent to store it directly.',
magent_ssl_private_key_pem:
'Paste the TLS private key PEM if you want Magent to store it directly.',
magent_notify_enabled:
'Master switch for Magent notifications. Individual provider toggles still apply.',
magent_notify_email_enabled: 'Enable SMTP email notifications.',
magent_notify_email_smtp_host: 'SMTP server hostname or IP.',
magent_notify_email_smtp_port: 'SMTP port (587 for STARTTLS, 465 for SSL).',
magent_notify_email_smtp_username: 'SMTP account username.',
magent_notify_email_smtp_password: 'SMTP account password or app password.',
magent_notify_email_from_address: 'Sender email address used by Magent.',
magent_notify_email_from_name: 'Sender display name shown to recipients.',
magent_notify_email_use_tls: 'Use STARTTLS after connecting to SMTP.',
magent_notify_email_use_ssl: 'Use implicit TLS/SSL for SMTP (usually port 465).',
magent_notify_discord_enabled: 'Enable Discord webhook notifications.',
magent_notify_discord_webhook_url:
'Discord channel webhook URL used for notifications and optional feedback routing.',
magent_notify_telegram_enabled: 'Enable Telegram notifications.',
magent_notify_telegram_bot_token: 'Bot token from BotFather.',
magent_notify_telegram_chat_id:
'Default Telegram chat/group/user ID for notifications.',
magent_notify_push_enabled: 'Enable generic push notifications.',
magent_notify_push_provider:
'Push backend to target (ntfy, gotify, pushover, webhook, etc.).',
magent_notify_push_base_url:
'Base URL for your push provider (for example ntfy/gotify server URL).',
magent_notify_push_topic: 'Topic/channel/room name used by the push provider.',
magent_notify_push_token: 'Provider token/API key/password.',
magent_notify_push_user_key:
'Provider recipient key/user key (for example Pushover user key).',
magent_notify_push_device:
'Optional device or target override, depending on provider.',
magent_notify_webhook_enabled: 'Enable generic webhook notifications.',
magent_notify_webhook_url:
'Generic webhook endpoint for custom integrations or automation flows.',
jellyseerr_base_url:
'Base URL for your Seerr server (FQDN or IP). Scheme is optional.',
jellyseerr_api_key: 'API key used to read requests and status.',
jellyfin_base_url:
'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.',
jellyfin_api_key: 'Admin API key for syncing users and availability.',
jellyfin_public_url:
'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).',
jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.',
artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.',
sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.',
sonarr_api_key: 'API key for Sonarr.',
bazarr_base_url: 'Bazarr server URL used for movie and episode subtitle repairs. Scheme is optional.',
bazarr_api_key: 'API key used to ask Bazarr for fresh subtitles.',
bazarr_default_language: 'Language code Bazarr should search for by default, such as en.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_root_folder: 'Root folder where Radarr stores movies.',
prowlarr_base_url:
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
prowlarr_api_key: 'API key for Prowlarr.',
qbittorrent_base_url:
'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.',
qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.',
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
requests_poll_interval_seconds:
'How often Magent checks if a full refresh should run.',
requests_delta_sync_interval_minutes:
'How often we poll for new or updated requests.',
requests_full_sync_time: 'Daily time to rebuild the full request cache.',
requests_cleanup_time: 'Daily time to trim old request history.',
requests_cleanup_days: 'History older than this is removed during cleanup.',
requests_data_source:
'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.',
site_banner_tone: 'Visual tone for the banner.',
site_login_show_jellyfin_login: 'Show the Jellyfin login button on the login page.',
site_login_show_local_login: 'Show the local Magent login button on the login page.',
site_login_show_forgot_password: 'Show the forgot-password link on the login page.',
site_login_show_signup_link: 'Show the invite signup link on the login page.',
site_nav_show_requests:
'Show the New Requests item in the top navigation. Disable it while request creation is unavailable.',
site_changelog: 'One update per line for the public changelog.',
}
const settingPlaceholders: Record<string, string> = {
magent_application_url: 'https://magent.example.com',
magent_application_port: '3000',
magent_api_url: 'https://api.example.com or https://magent.example.com/api',
magent_api_port: '8000',
magent_bind_host: '0.0.0.0',
magent_proxy_base_url: 'https://proxy.example.com/magent',
magent_proxy_forwarded_prefix: '/magent',
magent_ssl_certificate_path: '/certs/fullchain.pem',
magent_ssl_private_key_path: '/certs/privkey.pem',
magent_ssl_certificate_pem: '-----BEGIN CERTIFICATE-----',
magent_ssl_private_key_pem: '-----BEGIN PRIVATE KEY-----',
magent_notify_email_smtp_host: 'smtp.office365.com',
magent_notify_email_smtp_port: '587',
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',
magent_notify_push_base_url: 'https://ntfy.example.com or https://gotify.example.com',
magent_notify_push_topic: 'magent-alerts',
magent_notify_push_device: 'iphone-zak',
magent_notify_webhook_url: 'https://automation.example.com/webhooks/magent',
jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055',
jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096',
jellyfin_public_url: 'https://jelly.example.com',
sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989',
bazarr_base_url: 'https://bazarr.example.com or 10.30.1.81:6767',
bazarr_default_language: 'en',
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
}
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> = {}
for (const setting of items) {
const rawValue = formValues[setting.key]
if (typeof rawValue !== 'string') {
continue
}
const value = rawValue.trim()
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Update failed')
}
await loadSettings(new Set(sectionGroup.items.map((item) => item.key)))
if (sectionGroup.key === 'sonarr-connection') await loadOptions('sonarr')
if (sectionGroup.key === 'radarr-connection') await loadOptions('radarr')
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)
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 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: data?.warning ? 'error' : 'status',
message: data?.warning
? `SMTP accepted a relay-mode test for ${data?.recipient_email ?? 'the configured mailbox'}, but delivery is not guaranteed. ${data.warning}`
: `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()
await loadServiceStatuses()
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 }))
}
}
const syncJellyfinUsers = async () => {
setJellyfinSyncStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/jellyfin/users/sync`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sync failed')
}
const data = await response.json()
setJellyfinSyncStatus(`Imported ${data?.imported ?? 0} users from Jellyfin.`)
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not import Jellyfin users.'
setJellyfinSyncStatus(message)
}
}
const syncRequests = async () => {
setRequestsSyncStatus(null)
setRequestsSync({
status: 'running',
stored: 0,
total: 0,
skip: 0,
message: 'Starting sync',
})
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Sync failed')
}
const data = await response.json()
setRequestsSync(data?.sync ?? null)
setRequestsSyncStatus('Sync started.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not sync requests.'
setRequestsSyncStatus(message)
}
}
const syncRequestsDelta = async () => {
setRequestsSyncStatus(null)
setRequestsSync({
status: 'running',
stored: 0,
total: 0,
skip: 0,
message: 'Starting delta sync',
})
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync/delta`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Delta sync failed')
}
const data = await response.json()
setRequestsSync(data?.sync ?? null)
setRequestsSyncStatus('Delta sync started.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not run delta sync.'
setRequestsSyncStatus(message)
}
}
const prefetchArtwork = async () => {
setArtworkPrefetchStatus(null)
setArtworkPrefetch({
status: 'running',
processed: 0,
total: 0,
message: 'Starting artwork caching',
})
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/prefetch`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Artwork prefetch failed')
}
const data = await response.json()
setArtworkPrefetch(data?.prefetch ?? null)
setArtworkPrefetchStatus('Artwork caching started.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not cache artwork.'
setArtworkPrefetchStatus(message)
}
}
const prefetchArtworkMissing = async () => {
setArtworkPrefetchStatus(null)
setArtworkPrefetch({
status: 'running',
processed: 0,
total: 0,
message: 'Starting missing artwork caching',
})
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/requests/artwork/prefetch?only_missing=1`,
{ method: 'POST' }
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Missing artwork prefetch failed')
}
const data = await response.json()
setArtworkPrefetch(data?.prefetch ?? null)
setArtworkPrefetchStatus('Missing artwork caching started.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not cache missing artwork.'
setArtworkPrefetchStatus(message)
}
}
useEffect(() => {
const shouldSubscribe = showRequestsExtras || showArtworkExtras || showLogs
if (!shouldSubscribe) {
setLiveStreamConnected(false)
return
}
const token = getToken()
if (!token) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
let closed = false
let source: EventSource | null = null
const connect = async () => {
try {
const streamToken = await getEventStreamToken()
if (closed) return
const params = new URLSearchParams()
params.set('stream_token', streamToken)
if (showLogs) {
params.set('include_logs', '1')
params.set('log_lines', String(logsCount))
}
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
source = new EventSource(streamUrl)
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
try {
const payload = JSON.parse(event.data)
if (payload?.type !== 'admin_live_state') {
return
}
const rawSync =
payload.requestsSync && typeof payload.requestsSync === 'object'
? payload.requestsSync
: null
const nextSync = rawSync?.status === 'idle' ? null : rawSync
const prevSync = requestsSyncRef.current
requestsSyncRef.current = nextSync
setRequestsSync(nextSync)
if (
prevSync?.status === 'running' &&
nextSync?.status &&
nextSync.status !== 'running'
) {
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
}
const rawArtwork =
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
? payload.artworkPrefetch
: null
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
const prevArtwork = artworkPrefetchRef.current
artworkPrefetchRef.current = nextArtwork
setArtworkPrefetch(nextArtwork)
if (
prevArtwork?.status === 'running' &&
nextArtwork?.status &&
nextArtwork.status !== 'running'
) {
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
if (showArtworkExtras) {
void loadArtworkSummary()
}
}
if (payload.logs && typeof payload.logs === 'object') {
if (Array.isArray(payload.logs.lines)) {
setLogsLines(payload.logs.lines)
setLogsStatus(null)
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
setLogsStatus(payload.logs.error)
}
}
} catch (err) {
console.error(err)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
} catch (err) {
if (closed) return
setLiveStreamConnected(false)
console.error(err)
}
}
void connect()
return () => {
closed = true
setLiveStreamConnected(false)
source?.close()
}
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
useEffect(() => {
if (liveStreamConnected || !artworkPrefetch || artworkPrefetch.status !== 'running') {
return
}
let active = true
const timer = setInterval(async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`)
if (!response.ok) {
return
}
const data = await response.json()
if (!active) return
setArtworkPrefetch(data?.prefetch ?? null)
if (data?.prefetch?.status && data.prefetch.status !== 'running') {
setArtworkPrefetchStatus(data.prefetch.message || 'Artwork caching complete.')
void loadArtworkSummary()
}
} catch (err) {
console.error(err)
}
}, 2000)
return () => {
active = false
clearInterval(timer)
}
}, [artworkPrefetch, liveStreamConnected, loadArtworkSummary])
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
return
}
const timer = setTimeout(() => {
setArtworkPrefetch(null)
}, 5000)
return () => clearTimeout(timer)
}, [artworkPrefetch])
useEffect(() => {
if (liveStreamConnected || !requestsSync || requestsSync.status !== 'running') {
return
}
let active = true
const timer = setInterval(async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/sync/status`)
if (!response.ok) {
return
}
const data = await response.json()
if (!active) return
setRequestsSync(data?.sync ?? null)
if (data?.sync?.status && data.sync.status !== 'running') {
setRequestsSyncStatus(data.sync.message || 'Sync complete.')
}
} catch (err) {
console.error(err)
}
}, 2000)
return () => {
active = false
clearInterval(timer)
}
}, [liveStreamConnected, requestsSync])
useEffect(() => {
if (!requestsSync || requestsSync.status === 'running') {
return
}
const timer = setTimeout(() => {
setRequestsSync(null)
}, 5000)
return () => clearTimeout(timer)
}, [requestsSync])
const loadLogs = useCallback(async () => {
setLogsStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/logs?lines=${encodeURIComponent(String(logsCount))}`
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Log fetch failed')
}
const data = await response.json()
if (Array.isArray(data?.lines)) {
setLogsLines(data.lines)
} else {
setLogsLines([])
}
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not load logs.'
setLogsStatus(message)
}
}, [logsCount])
useEffect(() => {
if (!showLogs) {
return
}
if (liveStreamConnected) {
return
}
void loadLogs()
const timer = setInterval(() => {
void loadLogs()
}, 5000)
return () => clearInterval(timer)
}, [liveStreamConnected, loadLogs, showLogs])
const loadCache = async () => {
setCacheStatus(null)
setCacheLoading(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/requests/cache?limit=${encodeURIComponent(String(cacheCount))}`
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Cache fetch failed')
}
const data = await response.json()
if (Array.isArray(data?.rows)) {
setCacheRows(data.rows)
} else {
setCacheRows([])
}
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{"detail":"|"\\}$/g, '')
: 'Could not load cache.'
setCacheStatus(message)
} finally {
setCacheLoading(false)
}
}
const runRepair = async () => {
setMaintenanceStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/maintenance/repair`, { method: 'POST' })
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Repair failed')
}
const data = await response.json()
setMaintenanceStatus(`Integrity check: ${data?.integrity ?? 'unknown'}. Vacuum complete.`)
} catch (err) {
console.error(err)
setMaintenanceStatus('Database repair failed.')
}
}
const runCleanup = async () => {
setMaintenanceStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/maintenance/cleanup?days=90`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Cleanup failed')
}
const data = await response.json()
setMaintenanceStatus(
`Cleaned history older than ${data?.days ?? 90} days.`
)
} catch (err) {
console.error(err)
setMaintenanceStatus('Cleanup failed.')
}
}
const clearLogFile = async () => {
setMaintenanceStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/maintenance/logs/clear`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Clear logs failed')
}
setMaintenanceStatus('Log file cleared.')
setLogsLines([])
} catch (err) {
console.error(err)
setMaintenanceStatus('Clearing logs failed.')
}
}
const groupIsDirty = (group: SettingsSectionGroup) => group.items.some((item) => {
const current = (formValues[item.key] ?? '').trim()
const saved = item.sensitive ? '' : (item.value ?? '').trim()
return BOOL_SETTINGS.has(item.key)
? (current.toLowerCase() === 'true') !== (saved.toLowerCase() === 'true')
: current !== saved
})
const discardGroup = (group: SettingsSectionGroup) => {
setFormValues((current) => {
const next = { ...current }
for (const item of group.items) next[item.key] = item.sensitive ? '' : item.value ?? ''
return next
})
setSectionFeedback((current) => ({ ...current, [group.key]: { tone: 'status', message: 'Unsaved changes discarded.' } }))
}
const collapsedGroup = (group: SettingsSectionGroup) => {
if (['magent-binding', 'magent-proxy', 'magent-ssl', 'site-navigation', 'requests-advanced', 'logs-rotation', 'logs-components'].includes(group.key) || group.key.endsWith('-additional')) return true
if (group.key.startsWith('magent-notify-') && !['magent-notify-core', 'magent-notify-email'].includes(group.key)) {
const enabled = group.items.find((item) => item.key.endsWith('_enabled'))
return !enabled || String(enabled.value).toLowerCase() !== 'true'
}
return false
}
if (loading) {
return <main className="card">Loading admin settings...</main>
}
return (
<AdminShell
title={SECTION_LABELS[section] ?? 'Settings'}
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
{status && <div className="error-banner">{status}</div>}
{currentServiceStatus && (
<div className="config-service-status" role="status">
<span className={`config-connection-badge is-${currentServiceStatus.status}`}>{serviceStatusLabel(currentServiceStatus.status)}</span>
<span>{serviceStatusCheckedAt ? `Checked at ${new Date(serviceStatusCheckedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'Not checked yet'}</span>
<button type="button" className="ghost-button" onClick={() => void loadServiceStatuses()}>Refresh status</button>
</div>
)}
{renderedSettingsSections.length > 0 ? (
<div className="admin-form admin-zone-stack">
{renderedSettingsSections.map((sectionGroup) => (
<SettingsRegion id={`config-${sectionGroup.key}`} key={sectionGroup.key} title={sectionGroup.title} collapsed={collapsedGroup(sectionGroup)}>
<form onSubmit={(event) => { event.preventDefault(); void saveSettingGroup(sectionGroup) }}>
<div className="section-header">
<div className="config-subsection-heading">
<h2>{sectionGroup.title}</h2>
</div>
{sectionGroup.key === 'sonarr-library' && (
<button type="button" onClick={() => loadOptions('sonarr')}>
Refresh Sonarr options
</button>
)}
{sectionGroup.key === 'radarr-library' && (
<button type="button" onClick={() => loadOptions('radarr')}>
Refresh Radarr options
</button>
)}
{sectionGroup.key === 'jellyfin-users' && (
<button type="button" onClick={syncJellyfinUsers}>
Import Jellyfin users
</button>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' ? (
<div className="sync-actions">
<button type="button" onClick={prefetchArtwork}>
Cache all artwork now
</button>
<button
type="button"
className="ghost-button"
onClick={prefetchArtworkMissing}
>
Sync only missing artwork
</button>
</div>
) : null}
{showRequestsExtras && sectionGroup.key === 'requests-sync' && (
<div className="sync-actions-block">
<div className="sync-actions">
<button type="button" onClick={syncRequests}>
Run full refresh (rebuild cache)
</button>
<button type="button" className="ghost-button" onClick={syncRequestsDelta}>
Run delta sync (recent changes)
</button>
</div>
<div className="meta sync-note">
Full refresh rebuilds the entire cache. Delta sync only checks new or updated
requests.
</div>
</div>
)}
</div>
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && (
<p className="section-subtitle">
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
</p>
)}
{sectionGroup.key === 'sonarr-library' && sonarrError && (
<div className="error-banner">{sonarrError}</div>
)}
{sectionGroup.key === 'radarr-library' && radarrError && (
<div className="error-banner">{radarrError}</div>
)}
{sectionGroup.key === 'jellyfin-users' && jellyfinSyncStatus && (
<div className="status-banner">{jellyfinSyncStatus}</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
<div className="status-banner">{artworkPrefetchStatus}</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkSummaryStatus && (
<div className="status-banner">{artworkSummaryStatus}</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && (
<div className="summary">
<div className="summary-card">
<strong>Missing artwork</strong>
<p>{artworkSummary?.missing_artwork ?? '--'}</p>
<div className="meta">Requests missing poster/backdrop or cache files.</div>
</div>
<div className="summary-card">
<strong>Artwork cache size</strong>
<p>{formatBytes(artworkSummary?.cache_bytes)}</p>
<div className="meta">
{artworkSummary?.cache_files ?? '--'} cached files
</div>
</div>
<div className="summary-card">
<strong>Total requests</strong>
<p>{artworkSummary?.total_requests ?? '--'}</p>
<div className="meta">Requests currently tracked in cache.</div>
</div>
<div className="summary-card">
<strong>Cache mode</strong>
<p>{artworkSummary?.cache_mode ?? '--'}</p>
<div className="meta">Artwork setting applied to posters/backdrops.</div>
</div>
</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSyncStatus && (
<div className="status-banner">{requestsSyncStatus}</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetch && (
<div className="sync-progress">
<div className="sync-meta">
<span>Status: {artworkPrefetch.status}</span>
<span>
{artworkPrefetch.processed ?? 0}
{artworkPrefetch.total ? ` / ${artworkPrefetch.total}` : ''} cached
</span>
</div>
<div
className={`progress ${artworkPrefetch.status === 'completed' ? 'progress-complete' : ''}`}
>
<div
className="progress-fill"
style={{
width: `${computeProgressPercent(
artworkPrefetch.processed,
artworkPrefetch.total,
artworkPrefetch.status
)}%`,
}}
/>
</div>
{artworkPrefetch.message && <div className="meta">{artworkPrefetch.message}</div>}
</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSync && (
<div className="sync-progress">
<div className="sync-meta">
<span>Status: {requestsSync.status}</span>
<span>
{requestsSync.stored ?? 0}
{requestsSync.total ? ` / ${requestsSync.total}` : ''} synced
</span>
</div>
<div
className={`progress ${requestsSync.status === 'completed' ? 'progress-complete' : ''}`}
>
<div
className="progress-fill"
style={{
width: `${computeProgressPercent(
requestsSync.stored,
requestsSync.total,
requestsSync.status
)}%`,
}}
/>
</div>
{requestsSync.message && <div className="meta">{requestsSync.message}</div>}
</div>
)}
<div className="admin-grid">
{sectionGroup.items.filter((setting) => {
const enableKey = sectionGroup.items.find((item) => item.key.endsWith('_enabled'))?.key
if (enableKey && setting.key !== enableKey && formValues[enableKey] !== 'true') return false
if (sectionGroup.key === 'issues-resolution-confirmation' && setting.key !== 'issue_confirmation_contact_attempts' && formValues.issue_confirmation_contact_attempts === '0') return false
return true
}).map((setting) => {
const service = setting.key.startsWith('sonarr_') ? sonarrOptions : radarrOptions
const isProfile = setting.key === 'sonarr_quality_profile_id' || setting.key === 'radarr_quality_profile_id'
const isRoot = setting.key === 'sonarr_root_folder' || setting.key === 'radarr_root_folder'
const options = isProfile ? service?.qualityProfiles.map((item) => ({ value: String(item.id), label: item.name || item.label }))
: isRoot ? service?.rootFolders.map((item) => ({ value: String(item.id), label: item.path || item.label })) : undefined
return <SettingField key={setting.key} setting={setting} label={labelFromKey(setting.key)}
value={formValues[setting.key] ?? ''} help={settingDescriptions[setting.key]} placeholder={settingPlaceholders[setting.key]}
boolean={BOOL_SETTINGS.has(setting.key)} numeric={NUMBER_SETTINGS.has(setting.key)} multiline={TEXTAREA_SETTINGS.has(setting.key)}
options={options} optionsUnavailable={(isRoot || isProfile) && !service}
onChange={(value) => setFormValues((current) => ({ ...current, [setting.key]: value }))} />
})}
</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">
{groupIsDirty(sectionGroup) && <>
<span className="config-unsaved" role="status">Unsaved changes</span>
<button type="button" className="ghost-button" disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]} onClick={() => discardGroup(sectionGroup)}>Discard</button>
</>}
{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}
{getSectionTestLabel(sectionGroup.key) ? (
<button
type="button"
className="ghost-button settings-action-button"
onClick={() => void testSettingGroup(sectionGroup)}
disabled={groupIsDirty(sectionGroup) || sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
title={groupIsDirty(sectionGroup) ? 'Save changes before testing.' : undefined}
>
{sectionTesting[sectionGroup.key]
? 'Testing...'
: getSectionTestLabel(sectionGroup.key)}
</button>
) : null}
<button
type="submit"
className="settings-action-button"
disabled={!groupIsDirty(sectionGroup) || sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
>
{sectionSaving[sectionGroup.key] ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
</SettingsRegion>
))}
</div>
) : !showMaintenance && !showLogs && (
<div className="status-banner">
{section === 'magent'
? 'Magent runtime settings have moved to General. Notification provider settings have moved to Notifications.'
: 'No settings to show here yet. Try the Cache Control page for artwork and saved-request controls.'}
</div>
)}
{showLogs && (
<section className="admin-section admin-zone" id="logs">
<div className="section-header">
<h2>Activity log</h2>
<div className="log-actions">
<label className="recent-filter">
<span>Lines to show</span>
<select
value={logsCount}
onChange={(event) => setLogsCount(Number(event.target.value))}
>
<option value={100}>100</option>
<option value={200}>200</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
</label>
<button type="button" onClick={loadLogs}>
Refresh log
</button>
</div>
</div>
{logsStatus && <div className="error-banner">{logsStatus}</div>}
<pre className="log-viewer">{logsLines.join('')}</pre>
</section>
)}
{showCacheExtras && (
<section className="admin-section admin-zone" id="cache">
<div className="section-header">
<h2>Saved requests</h2>
<div className="config-inline-controls">
<label>Rows <select value={cacheCount} onChange={(event) => setCacheCount(Number(event.target.value))}>{[25, 50, 100, 200].map((count) => <option key={count} value={count}>{count}</option>)}</select></label>
<button type="button" onClick={loadCache} disabled={cacheLoading}>{cacheLoading ? 'Loading…' : 'Load saved requests'}</button>
</div>
</div>
{cacheStatus && <p className="error-banner" role="alert">{cacheStatus}</p>}
<div className="cache-table">
<div className="cache-row cache-head">
<span>Request</span>
<span>Title</span>
<span>Type</span>
<span>Status</span>
<span>Last update</span>
</div>
{cacheRows.length === 0 ? (
<div className="meta">No saved requests loaded yet.</div>
) : (
cacheRows.map((row) => (
<div key={row.request_id} className="cache-row">
<span>#{row.request_id}</span>
<span>{row.title || 'Untitled'}</span>
<span>{row.media_type || 'unknown'}</span>
<span>{row.status ?? 'n/a'}</span>
<span>{row.updated_at || row.created_at || 'n/a'}</span>
</div>
))
)}
</div>
</section>
)}
{showMaintenance && (
<section className="admin-section admin-zone" id="maintenance">
<div className="section-header">
<h2>Maintenance</h2>
</div>
<div className="maintenance-layout">
<div className="admin-panel maintenance-tools-panel">
<div className="maintenance-panel-copy">
<h3>Recovery and cleanup</h3>
<p className="lede">
Repair the database or remove old history when troubleshooting.
</p>
</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>
</div>
<a className="config-tool-link" href="/admin/diagnostics">Open system health and diagnostics </a>
</div>
</section>
)}
</AdminShell>
)
}