Files
Magent/frontend/app/admin/SettingsPage.tsx

1347 lines
51 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
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 }[]
}
const SECTION_LABELS: Record<string, string> = {
jellyseerr: 'Jellyseerr',
jellyfin: 'Jellyfin',
artwork: 'Artwork',
cache: 'Cache',
sonarr: 'Sonarr',
radarr: 'Radarr',
prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent',
log: 'Activity log',
requests: 'Request syncing',
site: 'Site',
}
const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled'])
const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog'])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record<string, string> = {
jellyseerr: 'Connect the request system where users submit content.',
jellyfin: 'Control Jellyfin login and availability checks.',
artwork: 'Configure how posters and artwork are loaded.',
cache: 'Manage saved request data and offline artwork.',
sonarr: 'TV automation settings.',
radarr: 'Movie automation settings.',
prowlarr: 'Indexer search settings.',
qbittorrent: 'Downloader connection settings.',
requests: 'Sync and refresh cadence for requests.',
log: 'Activity log for troubleshooting.',
site: 'Sitewide banner, version, and changelog details.',
}
const SETTINGS_SECTION_MAP: Record<string, string | null> = {
jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin',
artwork: 'artwork',
sonarr: 'sonarr',
radarr: 'radarr',
prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent',
requests: 'requests',
cache: null,
logs: 'log',
maintenance: null,
site: 'site',
}
const labelFromKey = (key: string) =>
key
.replaceAll('_', ' ')
.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', 'Refresh saved requests if older than (minutes)')
.replace('requests poll interval seconds', 'Background refresh check (seconds)')
.replace('requests delta sync interval minutes', 'Check for new or updated requests every (minutes)')
.replace('requests full sync time', 'Full refresh time (24h)')
.replace('requests cleanup time', 'Clean up old history time (24h)')
.replace('requests cleanup days', 'Remove history older than (days)')
.replace('requests data source', 'Where requests are loaded from')
.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 changelog', 'Changelog text')
type SettingsPageProps = {
section: string
}
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 [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 [requestsSync, setRequestsSync] = useState<any | null>(null)
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const loadSettings = useCallback(async () => {
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 = Array.isArray(data?.settings) ? data.settings : []
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(initialValues)
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 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.')
}
}
}, [])
useEffect(() => {
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
try {
await loadSettings()
if (section === 'artwork') {
await loadArtworkPrefetchStatus()
}
} 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, loadOptions, 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 visibleSections = settingsSection ? [settingsSection] : []
const isCacheSection = section === 'cache'
const cacheSettingKeys = new Set([
'requests_sync_ttl_minutes',
'requests_data_source',
'artwork_cache_mode',
])
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
const settingsSections = isCacheSection
? [{ key: 'cache', title: 'Cache settings', items: cacheSettings }]
: visibleSections.map((sectionKey) => ({
key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey,
items:
sectionKey === 'requests' || sectionKey === 'artwork'
? (groupedSettings[sectionKey] ?? []).filter(
(setting) => !cacheSettingKeys.has(setting.key)
)
: groupedSettings[sectionKey] ?? [],
}))
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') return true
return false
}
const settingDescriptions: Record<string, string> = {
jellyseerr_base_url: 'Base URL for your Jellyseerr server.',
jellyseerr_api_key: 'API key used to read requests and status.',
jellyfin_base_url: 'Local Jellyfin server URL for logins and lookups.',
jellyfin_api_key: 'Admin API key for syncing users and availability.',
jellyfin_public_url: 'Public Jellyfin URL used for the “Open in Jellyfin” button.',
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.',
sonarr_api_key: 'API key for Sonarr.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores TV shows.',
sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.',
radarr_base_url: 'Radarr server URL for movies.',
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.',
radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
prowlarr_base_url: 'Prowlarr server URL for indexer searches.',
prowlarr_api_key: 'API key for Prowlarr.',
qbittorrent_base_url: 'qBittorrent server URL for download status.',
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 the background checker runs.',
requests_delta_sync_interval_minutes: 'How often we check for new or updated requests.',
requests_full_sync_time: 'Daily time to refresh the full request list.',
requests_cleanup_time: 'Daily time to trim old 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 Jellyseerr lookups on reads.',
log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.',
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_changelog: 'One update per line for the public changelog.',
}
const buildSelectOptions = (
currentValue: string,
options: { id: number; label: string; path?: string }[],
includePath: boolean
) => {
const optionValues = new Set(options.map((option) => String(option.id)))
const list = options.map((option) => (
<option key={option.id} value={String(option.id)}>
{includePath && option.path ? option.path : option.label}
</option>
))
if (currentValue && !optionValues.has(currentValue)) {
list.unshift(
<option key="custom" value={currentValue}>
Custom: {currentValue}
</option>
)
}
return list
}
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
setStatus(null)
const payload: Record<string, string> = {}
const formData = new FormData(event.currentTarget)
for (const setting of settings) {
const rawValue = formData.get(setting.key)
if (typeof rawValue !== 'string') {
continue
}
const value = rawValue.trim()
if (value === '') {
continue
}
payload[setting.key] = value
}
try {
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')
}
setStatus('Settings saved. New values take effect immediately.')
await loadSettings()
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not save settings.'
setStatus(message)
}
}
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)
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)
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)
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)
}
}
useEffect(() => {
if (!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.')
}
} catch (err) {
console.error(err)
}
}, 2000)
return () => {
active = false
clearInterval(timer)
}
}, [artworkPrefetch])
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
return
}
const timer = setTimeout(() => {
setArtworkPrefetch(null)
}, 5000)
return () => clearTimeout(timer)
}, [artworkPrefetch])
useEffect(() => {
if (!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)
}
}, [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
}
void loadLogs()
const timer = setInterval(() => {
void loadLogs()
}, 5000)
return () => clearInterval(timer)
}, [loadLogs, showLogs])
const loadCache = async () => {
setCacheStatus(null)
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)
}
}
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 runFlushAndResync = async () => {
setMaintenanceStatus(null)
setMaintenanceBusy(true)
if (typeof window !== 'undefined') {
const ok = window.confirm(
'This will clear cached requests and history, then re-sync from Jellyseerr. Continue?'
)
if (!ok) {
setMaintenanceBusy(false)
return
}
}
try {
const baseUrl = getApiBase()
setMaintenanceStatus('Flushing database...')
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
method: 'POST',
})
if (!flushResponse.ok) {
const text = await flushResponse.text()
throw new Error(text || 'Flush failed')
}
setMaintenanceStatus('Database flushed. Starting re-sync...')
await syncRequests()
setMaintenanceStatus('Database flushed. Re-sync running now.')
} catch (err) {
console.error(err)
setMaintenanceStatus('Flush + resync failed.')
} finally {
setMaintenanceBusy(false)
}
}
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.')
}
}
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>
}
>
{settingsSections.length > 0 ? (
<form onSubmit={submit} className="admin-form">
{settingsSections
.filter(shouldRenderSection)
.map((sectionGroup) => (
<section key={sectionGroup.key} className="admin-section">
<div className="section-header">
<h2>{sectionGroup.key === 'requests' ? 'Sync controls' : sectionGroup.title}</h2>
{sectionGroup.key === 'sonarr' && (
<button type="button" onClick={() => loadOptions('sonarr')}>
Refresh Sonarr options
</button>
)}
{sectionGroup.key === 'radarr' && (
<button type="button" onClick={() => loadOptions('radarr')}>
Refresh Radarr options
</button>
)}
{sectionGroup.key === 'jellyfin' && (
<button type="button" onClick={syncJellyfinUsers}>
Import Jellyfin users
</button>
)}
{(showArtworkExtras && sectionGroup.key === 'artwork') ||
(showCacheExtras && sectionGroup.key === 'cache') ? (
<button type="button" onClick={prefetchArtwork}>
Cache all artwork now
</button>
) : null}
{showRequestsExtras && sectionGroup.key === 'requests' && (
<div className="sync-actions-block">
<div className="sync-actions">
<button type="button" onClick={syncRequests}>
Full refresh (all requests)
</button>
<button type="button" className="ghost-button" onClick={syncRequestsDelta}>
Quick refresh (delta changes)
</button>
</div>
<div className="meta sync-note">
Full refresh reloads the entire list. Quick refresh only checks recent changes.
</div>
</div>
)}
</div>
{SECTION_DESCRIPTIONS[sectionGroup.key] && !settingsSection && (
<p className="section-subtitle">{SECTION_DESCRIPTIONS[sectionGroup.key]}</p>
)}
{sectionGroup.key === 'sonarr' && sonarrError && (
<div className="error-banner">{sonarrError}</div>
)}
{sectionGroup.key === 'radarr' && radarrError && (
<div className="error-banner">{radarrError}</div>
)}
{sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
<div className="status-banner">{jellyfinSyncStatus}</div>
)}
{((showArtworkExtras && sectionGroup.key === 'artwork') ||
(showCacheExtras && sectionGroup.key === 'cache')) &&
artworkPrefetchStatus && (
<div className="status-banner">{artworkPrefetchStatus}</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
<div className="status-banner">{requestsSyncStatus}</div>
)}
{((showArtworkExtras && sectionGroup.key === 'artwork') ||
(showCacheExtras && sectionGroup.key === 'cache')) &&
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.total ? '' : 'progress-indeterminate'} ${
artworkPrefetch.status === 'completed' ? 'progress-complete' : ''
}`}
>
<div
className="progress-fill"
style={{
width:
artworkPrefetch.status === 'completed'
? '100%'
: artworkPrefetch.total
? `${Math.min(
100,
Math.round((artworkPrefetch.processed / artworkPrefetch.total) * 100)
)}%`
: '30%',
}}
/>
</div>
{artworkPrefetch.message && <div className="meta">{artworkPrefetch.message}</div>}
</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests' && 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.total ? '' : 'progress-indeterminate'} ${
requestsSync.status === 'completed' ? 'progress-complete' : ''
}`}
>
<div
className="progress-fill"
style={{
width:
requestsSync.status === 'completed'
? '100%'
: requestsSync.total
? `${Math.min(
100,
Math.round((requestsSync.stored / requestsSync.total) * 100)
)}%`
: '30%',
}}
/>
</div>
{requestsSync.message && <div className="meta">{requestsSync.message}</div>}
</div>
)}
<div className="admin-grid">
{sectionGroup.items.map((setting) => {
const value = formValues[setting.key] ?? ''
const helperText = settingDescriptions[setting.key]
const isSonarrProfile = setting.key === 'sonarr_quality_profile_id'
const isSonarrRoot = setting.key === 'sonarr_root_folder'
const isRadarrProfile = setting.key === 'radarr_quality_profile_id'
const isRadarrRoot = setting.key === 'radarr_root_folder'
const isBoolSetting = BOOL_SETTINGS.has(setting.key)
if (isBoolSetting) {
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 || 'false'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
</label>
)
}
if (isSonarrProfile && sonarrOptions) {
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'}
{setting.sensitive && setting.isSet ? ' • stored' : ''}
</span>
</span>
<select
name={setting.key}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="">Select a quality profile</option>
{buildSelectOptions(value, sonarrOptions.qualityProfiles, false)}
</select>
</label>
)
}
if (isSonarrRoot && sonarrOptions) {
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'}
{setting.sensitive && setting.isSet ? ' • stored' : ''}
</span>
</span>
<select
name={setting.key}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="">Select a root folder</option>
{buildSelectOptions(value, sonarrOptions.rootFolders, true)}
</select>
</label>
)
}
if (isRadarrProfile && radarrOptions) {
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'}
{setting.sensitive && setting.isSet ? ' • stored' : ''}
</span>
</span>
<select
name={setting.key}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="">Select a quality profile</option>
{buildSelectOptions(value, radarrOptions.qualityProfiles, false)}
</select>
</label>
)
}
if (isRadarrRoot && radarrOptions) {
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'}
{setting.sensitive && setting.isSet ? ' • stored' : ''}
</span>
</span>
<select
name={setting.key}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="">Select a root folder</option>
{buildSelectOptions(value, radarrOptions.rootFolders, true)}
</select>
</label>
)
}
if (setting.key === 'log_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}
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}>
<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 || 'remote'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="remote">Pull from the internet</option>
<option value="cache">Cache locally</option>
</select>
</label>
)
}
if (setting.key === 'site_banner_tone') {
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,
}))
}
>
{BANNER_TONES.map((tone) => (
<option key={tone} value={tone}>
{tone.charAt(0).toUpperCase() + tone.slice(1)}
</option>
))}
</select>
</label>
)
}
if (
setting.key === 'requests_full_sync_time' ||
setting.key === 'requests_cleanup_time'
) {
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>
<input
name={setting.key}
type="time"
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
if (
setting.key === 'requests_delta_sync_interval_minutes' ||
setting.key === 'requests_cleanup_days'
) {
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>
<input
name={setting.key}
type="number"
min={1}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
if (setting.key === 'requests_data_source') {
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 || 'prefer_cache'}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
>
<option value="always_js">Always use Jellyseerr (slower)</option>
<option value="prefer_cache">
Use saved requests only (fastest)
</option>
</select>
</label>
)
}
if (TEXTAREA_SETTINGS.has(setting.key)) {
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'}
{setting.sensitive && setting.isSet ? '  stored' : ''}
</span>
</span>
<textarea
name={setting.key}
rows={setting.key === 'site_changelog' ? 6 : 3}
placeholder={
setting.key === 'site_changelog'
? 'One update per line.'
: ''
}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
}
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'}
{setting.sensitive && setting.isSet ? ' • stored' : ''}
</span>
</span>
<input
name={setting.key}
type={setting.sensitive ? 'password' : 'text'}
placeholder={
setting.sensitive && setting.isSet ? 'Configured (enter to replace)' : ''
}
value={value}
onChange={(event) =>
setFormValues((current) => ({
...current,
[setting.key]: event.target.value,
}))
}
/>
</label>
)
})}
</div>
</section>
))}
{status && <div className="status-banner">{status}</div>}
<div className="admin-actions">
<button type="submit">Save changes</button>
</div>
</form>
) : (
<div className="status-banner">
No settings to show here yet. Try the Cache page for artwork and saved-request controls.
</div>
)}
{showLogs && (
<section className="admin-section" 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" id="cache">
<div className="section-header">
<h2>Saved requests (cache)</h2>
<div className="log-actions">
<label className="recent-filter">
<span>Rows to show</span>
<select
value={cacheCount}
onChange={(event) => setCacheCount(Number(event.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
<button type="button" onClick={loadCache}>
Load saved requests
</button>
</div>
</div>
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
<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" id="maintenance">
<div className="section-header">
<h2>Maintenance</h2>
</div>
<div className="status-banner">
Emergency tools. Use with care: flush will clear saved requests and history.
</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}
>
Flush database + resync
</button>
</div>
</section>
)}
{showRequestsExtras && (
<section className="admin-section" id="schedules">
<div className="section-header">
<h2>Scheduled tasks</h2>
</div>
<div className="status-banner">
Automated jobs keep requests and housekeeping up to date.
</div>
<div className="schedule-grid">
<div className="schedule-card">
<h3>Quick request check</h3>
<p>
Every {formValues.requests_delta_sync_interval_minutes || '5'} minutes, checks for
new or updated requests.
</p>
</div>
<div className="schedule-card">
<h3>Full daily refresh</h3>
<p>
Every day at {formValues.requests_full_sync_time || '00:00'}, refreshes the entire
requests list.
</p>
</div>
<div className="schedule-card">
<h3>History cleanup</h3>
<p>
Every day at {formValues.requests_cleanup_time || '02:00'}, removes history older
than {formValues.requests_cleanup_days || '90'} days.
</p>
</div>
</div>
</section>
)}
</AdminShell>
)
}