1488 lines
56 KiB
TypeScript
1488 lines
56 KiB
TypeScript
'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: 'Cache Control',
|
||
sonarr: 'Sonarr',
|
||
radarr: 'Radarr',
|
||
prowlarr: 'Prowlarr',
|
||
qbittorrent: 'qBittorrent',
|
||
log: 'Activity log',
|
||
requests: 'Request sync',
|
||
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: 'Cache posters/backdrops and review artwork coverage.',
|
||
cache: 'Manage saved requests cache and refresh behavior.',
|
||
sonarr: 'TV automation settings.',
|
||
radarr: 'Movie automation settings.',
|
||
prowlarr: 'Indexer search settings.',
|
||
qbittorrent: 'Downloader connection settings.',
|
||
requests: 'Control how often requests are refreshed and cleaned up.',
|
||
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: null,
|
||
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', '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 Jellyseerr)')
|
||
.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')
|
||
|
||
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
|
||
}
|
||
|
||
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 [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 [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 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.')
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
const load = async () => {
|
||
if (!getToken()) {
|
||
router.push('/login')
|
||
return
|
||
}
|
||
try {
|
||
await loadSettings()
|
||
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, 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'])
|
||
const artworkSettingKeys = new Set(['artwork_cache_mode'])
|
||
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys])
|
||
const requestSettingOrder = [
|
||
'requests_poll_interval_seconds',
|
||
'requests_delta_sync_interval_minutes',
|
||
'requests_full_sync_time',
|
||
'requests_cleanup_time',
|
||
'requests_cleanup_days',
|
||
]
|
||
const sortByOrder = (items: AdminSetting[], order: string[]) => {
|
||
const position = new Map(order.map((key, index) => [key, index]))
|
||
return [...items].sort((a, b) => {
|
||
const aIndex = position.get(a.key) ?? Number.POSITIVE_INFINITY
|
||
const bIndex = position.get(b.key) ?? Number.POSITIVE_INFINITY
|
||
if (aIndex !== bIndex) return aIndex - bIndex
|
||
return a.key.localeCompare(b.key)
|
||
})
|
||
}
|
||
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
|
||
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
|
||
const settingsSections = isCacheSection
|
||
? [
|
||
{ key: 'cache', title: 'Cache control', items: cacheSettings },
|
||
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
|
||
]
|
||
: visibleSections.map((sectionKey) => ({
|
||
key: sectionKey,
|
||
title: SECTION_LABELS[sectionKey] ?? sectionKey,
|
||
items: (() => {
|
||
const sectionItems = groupedSettings[sectionKey] ?? []
|
||
const filtered =
|
||
sectionKey === 'requests' || sectionKey === 'artwork'
|
||
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
|
||
: sectionItems
|
||
if (sectionKey === 'requests') {
|
||
return sortByOrder(filtered, requestSettingOrder)
|
||
}
|
||
return filtered
|
||
})(),
|
||
}))
|
||
const showLogs = section === 'logs'
|
||
const showMaintenance = section === 'maintenance'
|
||
const showRequestsExtras = section === 'requests'
|
||
const showArtworkExtras = section === 'cache'
|
||
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 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 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)
|
||
}
|
||
}
|
||
|
||
const prefetchArtworkMissing = async () => {
|
||
setArtworkPrefetchStatus(null)
|
||
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(() => {
|
||
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.')
|
||
void loadArtworkSummary()
|
||
}
|
||
} catch (err) {
|
||
console.error(err)
|
||
}
|
||
}, 2000)
|
||
return () => {
|
||
active = false
|
||
clearInterval(timer)
|
||
}
|
||
}, [artworkPrefetch, loadArtworkSummary])
|
||
|
||
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)
|
||
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 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' ? 'Request 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' ? (
|
||
<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' && (
|
||
<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>
|
||
{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' && 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' && requestsSyncStatus && (
|
||
<div className="status-banner">{requestsSyncStatus}</div>
|
||
)}
|
||
{showRequestsExtras && sectionGroup.key === 'requests' && (
|
||
<div className="status-banner">
|
||
Full refresh checks only decide when to run a full refresh. The delta sync interval
|
||
polls for new or updated requests.
|
||
</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.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 Control 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} disabled={cacheLoading}>
|
||
{cacheLoading ? (
|
||
<>
|
||
<span className="spinner button-spinner" aria-hidden="true" />
|
||
Loading saved requests
|
||
</>
|
||
) : (
|
||
'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>
|
||
)
|
||
}
|