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