2012 lines
77 KiB
TypeScript
2012 lines
77 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { authFetch, clearToken, getApiBase, getToken, getEventStreamToken } from '../lib/auth'
|
||
import AdminShell from '../ui/AdminShell'
|
||
|
||
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> = {
|
||
magent: 'Magent',
|
||
general: 'General',
|
||
notifications: 'Notifications',
|
||
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',
|
||
'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 URL_SETTINGS = new Set([
|
||
'magent_application_url',
|
||
'magent_api_url',
|
||
'magent_proxy_base_url',
|
||
'magent_notify_discord_webhook_url',
|
||
'magent_notify_push_base_url',
|
||
'magent_notify_webhook_url',
|
||
'jellyseerr_base_url',
|
||
'jellyfin_base_url',
|
||
'jellyfin_public_url',
|
||
'sonarr_base_url',
|
||
'radarr_base_url',
|
||
'prowlarr_base_url',
|
||
'qbittorrent_base_url',
|
||
])
|
||
const NUMBER_SETTINGS = new Set([
|
||
'magent_application_port',
|
||
'magent_api_port',
|
||
'magent_notify_email_smtp_port',
|
||
'requests_sync_ttl_minutes',
|
||
'requests_poll_interval_seconds',
|
||
'requests_delta_sync_interval_minutes',
|
||
'requests_cleanup_days',
|
||
])
|
||
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
|
||
|
||
const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||
magent:
|
||
'Magent service settings. Runtime and notification controls are organized under General and Notifications.',
|
||
general:
|
||
'Application runtime, binding, reverse proxy, and manual SSL settings for the Magent UI/API.',
|
||
notifications:
|
||
'Notification providers and delivery channel settings used by Magent messaging features.',
|
||
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> = {
|
||
magent: 'magent',
|
||
general: 'magent',
|
||
notifications: 'magent',
|
||
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 MAGENT_SECTION_GROUPS: Array<{
|
||
key: string
|
||
title: string
|
||
description: string
|
||
keys: string[]
|
||
}> = [
|
||
{
|
||
key: 'magent-runtime',
|
||
title: 'Application',
|
||
description:
|
||
'Canonical application/API URLs and port defaults for the Magent UI/API endpoints.',
|
||
keys: [
|
||
'magent_application_url',
|
||
'magent_application_port',
|
||
'magent_api_url',
|
||
'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: 'Manual SSL Bind',
|
||
description:
|
||
'Optional direct TLS binding values. Paste PEM certificate and private key or provide file paths.',
|
||
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',
|
||
'magent_notify_webhook_enabled',
|
||
'magent_notify_webhook_url',
|
||
],
|
||
},
|
||
]
|
||
|
||
const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
|
||
general: new Set(['magent-runtime', 'magent-proxy', 'magent-ssl']),
|
||
notifications: new Set([
|
||
'magent-notify-core',
|
||
'magent-notify-email',
|
||
'magent-notify-discord',
|
||
'magent-notify-telegram',
|
||
'magent-notify-push',
|
||
]),
|
||
}
|
||
|
||
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
||
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',
|
||
}
|
||
|
||
const labelFromKey = (key: string) =>
|
||
SETTING_LABEL_OVERRIDES[key] ??
|
||
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
|
||
}
|
||
|
||
type SettingsSectionGroup = {
|
||
key: string
|
||
title: string
|
||
items: AdminSetting[]
|
||
description?: 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 [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
||
const requestsSyncRef = useRef<any | null>(null)
|
||
const artworkPrefetchRef = useRef<any | null>(null)
|
||
|
||
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 isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
||
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: SettingsSectionGroup[] = isCacheSection
|
||
? [
|
||
{ key: 'cache', title: 'Cache control', items: cacheSettings },
|
||
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
|
||
]
|
||
: isMagentGroupedSection
|
||
? (() => {
|
||
if (section === 'magent') {
|
||
return []
|
||
}
|
||
const magentItems = groupedSettings.magent ?? []
|
||
const byKey = new Map(magentItems.map((item) => [item.key, item]))
|
||
const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set<string>()
|
||
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) =>
|
||
allowedGroupKeys.has(group.key),
|
||
).map((group) => {
|
||
const items = group.keys
|
||
.map((key) => byKey.get(key))
|
||
.filter((item): item is AdminSetting => Boolean(item))
|
||
return {
|
||
key: group.key,
|
||
title: group.title,
|
||
description: group.description,
|
||
items,
|
||
}
|
||
})
|
||
return groups
|
||
})()
|
||
: 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
|
||
}
|
||
|
||
useEffect(() => {
|
||
requestsSyncRef.current = requestsSync
|
||
}, [requestsSync])
|
||
|
||
useEffect(() => {
|
||
artworkPrefetchRef.current = artworkPrefetch
|
||
}, [artworkPrefetch])
|
||
|
||
const settingDescriptions: Record<string, string> = {
|
||
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 Jellyseerr 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.',
|
||
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 (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.',
|
||
radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
|
||
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 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 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',
|
||
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',
|
||
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 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(() => {
|
||
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 || 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
|
||
console.error(err)
|
||
setLiveStreamConnected(false)
|
||
}
|
||
}
|
||
|
||
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 runFlushAndResync = async () => {
|
||
setMaintenanceStatus(null)
|
||
setMaintenanceBusy(true)
|
||
if (typeof window !== 'undefined') {
|
||
const ok = window.confirm(
|
||
'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Jellyseerr. Continue?'
|
||
)
|
||
if (!ok) {
|
||
setMaintenanceBusy(false)
|
||
return
|
||
}
|
||
}
|
||
try {
|
||
const baseUrl = getApiBase()
|
||
setMaintenanceStatus('Running nuclear flush...')
|
||
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
|
||
method: 'POST',
|
||
})
|
||
if (!flushResponse.ok) {
|
||
const text = await flushResponse.text()
|
||
throw new Error(text || 'Flush failed')
|
||
}
|
||
const flushData = await flushResponse.json()
|
||
const usersCleared = Number(flushData?.userObjectsCleared?.users ?? 0)
|
||
setMaintenanceStatus(`Nuclear flush complete. Cleared ${usersCleared} non-admin users. Re-syncing users...`)
|
||
const usersResyncResponse = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
|
||
method: 'POST',
|
||
})
|
||
if (!usersResyncResponse.ok) {
|
||
const text = await usersResyncResponse.text()
|
||
throw new Error(text || 'User resync failed')
|
||
}
|
||
const usersResyncData = await usersResyncResponse.json()
|
||
setMaintenanceStatus(
|
||
`Users re-synced (${usersResyncData?.imported ?? 0} imported). Starting request re-sync...`
|
||
)
|
||
await syncRequests()
|
||
setMaintenanceStatus('Nuclear flush complete. User and request re-sync running now.')
|
||
} catch (err) {
|
||
console.error(err)
|
||
setMaintenanceStatus('Nuclear 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>
|
||
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
|
||
(!settingsSection || isMagentGroupedSection) && (
|
||
<p className="section-subtitle">
|
||
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
|
||
</p>
|
||
)}
|
||
{section === 'general' && sectionGroup.key === 'magent-runtime' && (
|
||
<div className="status-banner">
|
||
Runtime host/port and SSL values are configuration settings. Container/process
|
||
restarts may still be required before bind/port changes take effect.
|
||
</div>
|
||
)}
|
||
{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)
|
||
const isUrlSetting = URL_SETTINGS.has(setting.key)
|
||
const inputPlaceholder = setting.sensitive && setting.isSet
|
||
? 'Configured (enter to replace)'
|
||
: settingPlaceholders[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 === 'magent_notify_push_provider') {
|
||
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 || 'ntfy'}
|
||
onChange={(event) =>
|
||
setFormValues((current) => ({
|
||
...current,
|
||
[setting.key]: event.target.value,
|
||
}))
|
||
}
|
||
>
|
||
<option value="ntfy">ntfy</option>
|
||
<option value="gotify">Gotify</option>
|
||
<option value="pushover">Pushover</option>
|
||
<option value="webhook">Webhook</option>
|
||
<option value="telegram">Telegram relay</option>
|
||
<option value="discord">Discord relay</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 (NUMBER_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'}
|
||
</span>
|
||
</span>
|
||
<input
|
||
name={setting.key}
|
||
type="number"
|
||
min={1}
|
||
step={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)) {
|
||
const isPemField =
|
||
setting.key === 'magent_ssl_certificate_pem' ||
|
||
setting.key === 'magent_ssl_private_key_pem'
|
||
return (
|
||
<label
|
||
key={setting.key}
|
||
data-helper={helperText || undefined}
|
||
className={isPemField ? 'field-span-full' : 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 : isPemField ? 8 : 3}
|
||
placeholder={
|
||
setting.key === 'site_changelog'
|
||
? 'One update per line.'
|
||
: settingPlaceholders[setting.key] ?? ''
|
||
}
|
||
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={inputPlaceholder}
|
||
autoComplete={isUrlSetting ? 'url' : undefined}
|
||
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">
|
||
{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" 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 + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Jellyseerr users/requests.
|
||
</div>
|
||
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
|
||
<div className="maintenance-grid">
|
||
<button type="button" onClick={runRepair}>
|
||
Repair database
|
||
</button>
|
||
<button type="button" className="ghost-button" onClick={runCleanup}>
|
||
Clean history (older than 90 days)
|
||
</button>
|
||
<button type="button" className="ghost-button" onClick={clearLogFile}>
|
||
Clear activity log
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="danger-button"
|
||
onClick={runFlushAndResync}
|
||
disabled={maintenanceBusy}
|
||
>
|
||
Nuclear flush + resync
|
||
</button>
|
||
</div>
|
||
</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>
|
||
)
|
||
}
|