Reorganize admin configuration sections
This commit is contained in:
+267
-122
@@ -33,14 +33,15 @@ const SECTION_LABELS: Record<string, string> = {
|
|||||||
jellyseerr: 'Seerr',
|
jellyseerr: 'Seerr',
|
||||||
jellyfin: 'Jellyfin',
|
jellyfin: 'Jellyfin',
|
||||||
artwork: 'Artwork cache',
|
artwork: 'Artwork cache',
|
||||||
cache: 'Cache Control',
|
cache: 'Request cache',
|
||||||
sonarr: 'Sonarr',
|
sonarr: 'Sonarr',
|
||||||
radarr: 'Radarr',
|
radarr: 'Radarr',
|
||||||
prowlarr: 'Prowlarr',
|
prowlarr: 'Prowlarr',
|
||||||
qbittorrent: 'qBittorrent',
|
qbittorrent: 'qBittorrent',
|
||||||
log: 'Activity log',
|
logs: 'Activity log',
|
||||||
requests: 'Request sync',
|
maintenance: 'Maintenance',
|
||||||
site: 'Site',
|
requests: 'Request pipeline',
|
||||||
|
site: 'Site & login',
|
||||||
}
|
}
|
||||||
|
|
||||||
const BOOL_SETTINGS = new Set([
|
const BOOL_SETTINGS = new Set([
|
||||||
@@ -114,7 +115,8 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
|||||||
prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
|
prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
|
||||||
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
|
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
|
||||||
requests: 'Control how often requests are refreshed and cleaned up.',
|
requests: 'Control how often requests are refreshed and cleaned up.',
|
||||||
log: 'Activity log for troubleshooting.',
|
logs: 'Control log output and inspect recent application activity for troubleshooting.',
|
||||||
|
maintenance: 'Repair cached data, clean historical records, and run recovery operations.',
|
||||||
site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.',
|
site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,10 +235,14 @@ const MAGENT_SECTION_GROUPS: Array<{
|
|||||||
'magent_notify_push_token',
|
'magent_notify_push_token',
|
||||||
'magent_notify_push_user_key',
|
'magent_notify_push_user_key',
|
||||||
'magent_notify_push_device',
|
'magent_notify_push_device',
|
||||||
'magent_notify_webhook_enabled',
|
|
||||||
'magent_notify_webhook_url',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
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>> = {
|
const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
|
||||||
@@ -247,6 +253,7 @@ const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
|
|||||||
'magent-notify-discord',
|
'magent-notify-discord',
|
||||||
'magent-notify-telegram',
|
'magent-notify-telegram',
|
||||||
'magent-notify-push',
|
'magent-notify-push',
|
||||||
|
'magent-notify-webhook',
|
||||||
]),
|
]),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +288,130 @@ const SITE_SECTION_GROUPS: Array<{
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const STANDARD_SECTION_GROUPS: Record<
|
||||||
|
string,
|
||||||
|
Array<{ key: string; title: string; description: string; keys: string[] }>
|
||||||
|
> = {
|
||||||
|
seerr: [
|
||||||
|
{
|
||||||
|
key: 'seerr-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'The Seerr endpoint and API credential Magent uses for request discovery and status.',
|
||||||
|
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
jellyseerr: [
|
||||||
|
{
|
||||||
|
key: 'seerr-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'The Seerr endpoint and API credential Magent uses for request discovery and status.',
|
||||||
|
keys: ['jellyseerr_base_url', 'jellyseerr_api_key'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
jellyfin: [
|
||||||
|
{
|
||||||
|
key: 'jellyfin-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'Internal Jellyfin endpoint and administrator API credential used for lookups and user sync.',
|
||||||
|
keys: ['jellyfin_base_url', 'jellyfin_api_key'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jellyfin-playback',
|
||||||
|
title: 'Playback Links',
|
||||||
|
description: 'Public address used when a viewer opens an available title from Magent.',
|
||||||
|
keys: ['jellyfin_public_url'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'jellyfin-users',
|
||||||
|
title: 'Library and User Sync',
|
||||||
|
description: 'Control cross-service library reconciliation and manually import Jellyfin users.',
|
||||||
|
keys: ['jellyfin_sync_to_arr'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
sonarr: [
|
||||||
|
{
|
||||||
|
key: 'sonarr-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'Sonarr endpoint and API credential used for TV collection operations.',
|
||||||
|
keys: ['sonarr_base_url', 'sonarr_api_key'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sonarr-library',
|
||||||
|
title: 'TV Collection Defaults',
|
||||||
|
description: 'Default quality profile and destination folder used for TV requests.',
|
||||||
|
keys: ['sonarr_quality_profile_id', 'sonarr_root_folder'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
radarr: [
|
||||||
|
{
|
||||||
|
key: 'radarr-connection',
|
||||||
|
title: 'Connection',
|
||||||
|
description: 'Radarr endpoint and API credential used for movie collection operations.',
|
||||||
|
keys: ['radarr_base_url', 'radarr_api_key'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'radarr-library',
|
||||||
|
title: 'Movie Collection Defaults',
|
||||||
|
description: 'Default quality profile and destination folder used for movie requests.',
|
||||||
|
keys: ['radarr_quality_profile_id', 'radarr_root_folder'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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-sync',
|
||||||
|
title: 'Synchronization Schedule',
|
||||||
|
description: 'Control incremental checks and the scheduled full request-cache rebuild.',
|
||||||
|
keys: [
|
||||||
|
'requests_poll_interval_seconds',
|
||||||
|
'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'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
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> = {
|
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
||||||
jellyseerr_base_url: 'Seerr base URL',
|
jellyseerr_base_url: 'Seerr base URL',
|
||||||
jellyseerr_api_key: 'Seerr API key',
|
jellyseerr_api_key: 'Seerr API key',
|
||||||
@@ -322,6 +453,33 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
magent_notify_push_device: 'Device / target',
|
magent_notify_push_device: 'Device / target',
|
||||||
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
|
magent_notify_webhook_enabled: 'Generic webhook notifications enabled',
|
||||||
magent_notify_webhook_url: 'Generic webhook URL',
|
magent_notify_webhook_url: 'Generic webhook URL',
|
||||||
|
jellyfin_base_url: 'Internal server URL',
|
||||||
|
jellyfin_api_key: 'Administrator API key',
|
||||||
|
jellyfin_public_url: 'Public playback URL',
|
||||||
|
jellyfin_sync_to_arr: 'Reconcile Jellyfin with Sonarr and Radarr',
|
||||||
|
sonarr_base_url: 'Sonarr server URL',
|
||||||
|
sonarr_api_key: 'Sonarr API key',
|
||||||
|
sonarr_quality_profile_id: 'Default TV quality profile',
|
||||||
|
sonarr_root_folder: 'Default TV root folder',
|
||||||
|
radarr_base_url: 'Radarr server URL',
|
||||||
|
radarr_api_key: 'Radarr API key',
|
||||||
|
radarr_quality_profile_id: 'Default movie quality profile',
|
||||||
|
radarr_root_folder: 'Default movie root folder',
|
||||||
|
prowlarr_base_url: 'Prowlarr server URL',
|
||||||
|
prowlarr_api_key: 'Prowlarr API key',
|
||||||
|
qbittorrent_base_url: 'Web UI URL',
|
||||||
|
qbittorrent_username: 'Web UI username',
|
||||||
|
qbittorrent_password: 'Web UI password',
|
||||||
|
requests_sync_ttl_minutes: 'Request cache freshness (minutes)',
|
||||||
|
requests_poll_interval_seconds: 'Full-sync eligibility check (seconds)',
|
||||||
|
requests_delta_sync_interval_minutes: 'Recent-change sync interval (minutes)',
|
||||||
|
requests_full_sync_time: 'Daily full-sync time',
|
||||||
|
requests_cleanup_time: 'Daily history cleanup time',
|
||||||
|
requests_cleanup_days: 'History retention (days)',
|
||||||
|
requests_data_source: 'Request read source',
|
||||||
|
artwork_cache_mode: 'Artwork delivery mode',
|
||||||
|
log_level: 'Application log level',
|
||||||
|
log_file: 'Active log file',
|
||||||
site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
|
site_login_show_jellyfin_login: 'Login page: Jellyfin sign-in',
|
||||||
site_login_show_local_login: 'Login page: local Magent 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_forgot_password: 'Login page: forgot password',
|
||||||
@@ -390,12 +548,12 @@ type SectionFeedback = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
|
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
|
||||||
jellyseerr: 'seerr',
|
'seerr-connection': 'seerr',
|
||||||
jellyfin: 'jellyfin',
|
'jellyfin-connection': 'jellyfin',
|
||||||
sonarr: 'sonarr',
|
'sonarr-connection': 'sonarr',
|
||||||
radarr: 'radarr',
|
'radarr-connection': 'radarr',
|
||||||
prowlarr: 'prowlarr',
|
'prowlarr-connection': 'prowlarr',
|
||||||
qbittorrent: 'qbittorrent',
|
'qbittorrent-connection': 'qbittorrent',
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SettingsPage({ section }: SettingsPageProps) {
|
export default function SettingsPage({ section }: SettingsPageProps) {
|
||||||
@@ -633,8 +791,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
: null
|
: null
|
||||||
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
||||||
const isSiteGroupedSection = section === 'site'
|
const isSiteGroupedSection = section === 'site'
|
||||||
const visibleSections = settingsSection ? [settingsSection] : []
|
|
||||||
const isCacheSection = section === 'cache'
|
const isCacheSection = section === 'cache'
|
||||||
|
const isArtworkSection = section === 'artwork'
|
||||||
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
|
||||||
const artworkSettingKeys = new Set(['artwork_cache_mode'])
|
const artworkSettingKeys = new Set(['artwork_cache_mode'])
|
||||||
const generatedSettingKeys = new Set(['site_changelog'])
|
const generatedSettingKeys = new Set(['site_changelog'])
|
||||||
@@ -643,38 +801,59 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
'sonarr_qbittorrent_category',
|
'sonarr_qbittorrent_category',
|
||||||
'radarr_qbittorrent_category',
|
'radarr_qbittorrent_category',
|
||||||
])
|
])
|
||||||
const requestSettingOrder = [
|
|
||||||
'requests_poll_interval_seconds',
|
|
||||||
'requests_delta_sync_interval_minutes',
|
|
||||||
'requests_full_sync_time',
|
|
||||||
'requests_cleanup_time',
|
|
||||||
'requests_cleanup_days',
|
|
||||||
]
|
|
||||||
const siteSettingOrder = [
|
|
||||||
'site_banner_enabled',
|
|
||||||
'site_banner_message',
|
|
||||||
'site_banner_tone',
|
|
||||||
'site_login_show_jellyfin_login',
|
|
||||||
'site_login_show_local_login',
|
|
||||||
'site_login_show_forgot_password',
|
|
||||||
'site_login_show_signup_link',
|
|
||||||
'site_nav_show_requests',
|
|
||||||
]
|
|
||||||
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 cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
|
||||||
const artworkSettings = settings.filter((setting) => artworkSettingKeys.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
|
const settingsSections: SettingsSectionGroup[] = isCacheSection
|
||||||
? [
|
? [
|
||||||
{ key: 'cache', title: 'Cache control', items: cacheSettings },
|
{
|
||||||
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
|
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
|
: isMagentGroupedSection
|
||||||
? (() => {
|
? (() => {
|
||||||
@@ -682,72 +861,31 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
const magentItems = groupedSettings.magent ?? []
|
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 allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set<string>()
|
||||||
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) =>
|
const definitions = MAGENT_SECTION_GROUPS.filter((group) => allowedGroupKeys.has(group.key))
|
||||||
allowedGroupKeys.has(group.key),
|
return buildDefinedSections(definitions, magentItems, false)
|
||||||
).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
|
|
||||||
})()
|
})()
|
||||||
: isSiteGroupedSection
|
: isSiteGroupedSection
|
||||||
? (() => {
|
? buildDefinedSections(
|
||||||
const siteItems = groupedSettings.site ?? []
|
SITE_SECTION_GROUPS,
|
||||||
const byKey = new Map(siteItems.map((item) => [item.key, item]))
|
(groupedSettings.site ?? []).filter((setting) => !hiddenSettingKeys.has(setting.key)),
|
||||||
return SITE_SECTION_GROUPS.map((group) => {
|
)
|
||||||
const items = group.keys
|
: standardDefinitions
|
||||||
.map((key) => byKey.get(key))
|
? buildDefinedSections(standardDefinitions, standardItems)
|
||||||
.filter((item): item is AdminSetting => Boolean(item))
|
: []
|
||||||
return {
|
|
||||||
key: group.key,
|
|
||||||
title: group.title,
|
|
||||||
description: group.description,
|
|
||||||
items,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})()
|
|
||||||
: visibleSections.map((sectionKey) => ({
|
|
||||||
key: sectionKey,
|
|
||||||
title: SECTION_LABELS[sectionKey] ?? sectionKey,
|
|
||||||
items: (() => {
|
|
||||||
const sectionItems = groupedSettings[sectionKey] ?? []
|
|
||||||
const filtered = sectionItems.filter((setting) => {
|
|
||||||
if (obsoleteSettingKeys.has(setting.key)) return false
|
|
||||||
if (sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site') {
|
|
||||||
return !hiddenSettingKeys.has(setting.key)
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
if (sectionKey === 'requests') {
|
|
||||||
return sortByOrder(filtered, requestSettingOrder)
|
|
||||||
}
|
|
||||||
if (sectionKey === 'site') {
|
|
||||||
return sortByOrder(filtered, siteSettingOrder)
|
|
||||||
}
|
|
||||||
return filtered
|
|
||||||
})(),
|
|
||||||
}))
|
|
||||||
const showLogs = section === 'logs'
|
const showLogs = section === 'logs'
|
||||||
const showMaintenance = section === 'maintenance'
|
const showMaintenance = section === 'maintenance'
|
||||||
const showRequestsExtras = section === 'requests'
|
const showRequestsExtras = section === 'requests'
|
||||||
const showArtworkExtras = section === 'cache'
|
const showArtworkExtras = section === 'artwork'
|
||||||
const showCacheExtras = section === 'cache'
|
const showCacheExtras = section === 'cache'
|
||||||
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
|
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
|
||||||
if (sectionGroup.items && sectionGroup.items.length > 0) return true
|
if (sectionGroup.items && sectionGroup.items.length > 0) return true
|
||||||
if (showArtworkExtras && sectionGroup.key === 'artwork') return true
|
if (showArtworkExtras && sectionGroup.key === 'artwork') return true
|
||||||
if (showCacheExtras && sectionGroup.key === 'cache') return true
|
if (showCacheExtras && sectionGroup.key === 'cache') return true
|
||||||
if (showRequestsExtras && sectionGroup.key === 'requests') return true
|
if (showRequestsExtras && sectionGroup.key === 'requests-sync') return true
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
const renderedSettingsSections = settingsSections.filter(shouldRenderSection)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
requestsSyncRef.current = requestsSync
|
requestsSyncRef.current = requestsSync
|
||||||
@@ -1632,10 +1770,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
<span>Maintenance job</span>
|
<span>Maintenance job</span>
|
||||||
<strong>{maintenanceBusy ? 'Running' : 'Idle'}</strong>
|
<strong>{maintenanceBusy ? 'Running' : 'Idle'}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="cache-rail-metric">
|
|
||||||
<span>Live updates</span>
|
|
||||||
<strong>{liveStreamConnected ? 'Connected' : 'Polling'}</strong>
|
|
||||||
</div>
|
|
||||||
<div className="cache-rail-metric">
|
<div className="cache-rail-metric">
|
||||||
<span>Log lines in view</span>
|
<span>Log lines in view</span>
|
||||||
<strong>{logsLines.length}</strong>
|
<strong>{logsLines.length}</strong>
|
||||||
@@ -1649,7 +1783,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : undefined
|
) : undefined
|
||||||
const cacheRail = showCacheExtras ? (
|
const cacheRail = showCacheExtras ? (
|
||||||
<div className="admin-rail-stack">
|
|
||||||
<div className="admin-rail-card cache-rail-card">
|
<div className="admin-rail-card cache-rail-card">
|
||||||
<span className="admin-rail-eyebrow">Cache control</span>
|
<span className="admin-rail-eyebrow">Cache control</span>
|
||||||
<h2>Saved requests</h2>
|
<h2>Saved requests</h2>
|
||||||
@@ -1696,6 +1829,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
|
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
) : undefined
|
||||||
|
const artworkRail = showArtworkExtras ? (
|
||||||
<div className="admin-rail-card cache-rail-card">
|
<div className="admin-rail-card cache-rail-card">
|
||||||
<span className="admin-rail-eyebrow">Artwork</span>
|
<span className="admin-rail-eyebrow">Artwork</span>
|
||||||
<h2>Cache stats</h2>
|
<h2>Cache stats</h2>
|
||||||
@@ -1718,7 +1853,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : undefined
|
) : undefined
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -1729,7 +1863,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
<AdminShell
|
<AdminShell
|
||||||
title={SECTION_LABELS[section] ?? 'Settings'}
|
title={SECTION_LABELS[section] ?? 'Settings'}
|
||||||
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
||||||
rail={maintenanceRail ?? cacheRail}
|
rail={maintenanceRail ?? cacheRail ?? artworkRail}
|
||||||
actions={
|
actions={
|
||||||
<button type="button" onClick={() => router.push('/admin')}>
|
<button type="button" onClick={() => router.push('/admin')}>
|
||||||
Back to settings
|
Back to settings
|
||||||
@@ -1770,27 +1904,39 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
{settingsSections.length > 0 ? (
|
{renderedSettingsSections.length > 1 ? (
|
||||||
|
<nav className="config-subsection-nav" aria-label={`${SECTION_LABELS[section] ?? 'Settings'} subsections`}>
|
||||||
|
<span>On this page</span>
|
||||||
|
<div>
|
||||||
|
{renderedSettingsSections.map((sectionGroup, index) => (
|
||||||
|
<a key={sectionGroup.key} href={`#config-${sectionGroup.key}`}>
|
||||||
|
<small>{String(index + 1).padStart(2, '0')}</small>
|
||||||
|
{sectionGroup.title}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
{renderedSettingsSections.length > 0 ? (
|
||||||
<div className="admin-form admin-zone-stack">
|
<div className="admin-form admin-zone-stack">
|
||||||
{settingsSections
|
{renderedSettingsSections.map((sectionGroup, sectionIndex) => (
|
||||||
.filter(shouldRenderSection)
|
<section id={`config-${sectionGroup.key}`} key={sectionGroup.key} className="admin-section admin-zone config-subsection">
|
||||||
.map((sectionGroup) => (
|
|
||||||
<section key={sectionGroup.key} className="admin-section admin-zone">
|
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>
|
<div className="config-subsection-heading">
|
||||||
{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
|
<span className="section-kicker">Subsection {String(sectionIndex + 1).padStart(2, '0')}</span>
|
||||||
</h2>
|
<h2>{sectionGroup.title}</h2>
|
||||||
{sectionGroup.key === 'sonarr' && (
|
</div>
|
||||||
|
{sectionGroup.key === 'sonarr-library' && (
|
||||||
<button type="button" onClick={() => loadOptions('sonarr')}>
|
<button type="button" onClick={() => loadOptions('sonarr')}>
|
||||||
Refresh Sonarr options
|
Refresh Sonarr options
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{sectionGroup.key === 'radarr' && (
|
{sectionGroup.key === 'radarr-library' && (
|
||||||
<button type="button" onClick={() => loadOptions('radarr')}>
|
<button type="button" onClick={() => loadOptions('radarr')}>
|
||||||
Refresh Radarr options
|
Refresh Radarr options
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{sectionGroup.key === 'jellyfin' && (
|
{sectionGroup.key === 'jellyfin-users' && (
|
||||||
<button type="button" onClick={syncJellyfinUsers}>
|
<button type="button" onClick={syncJellyfinUsers}>
|
||||||
Import Jellyfin users
|
Import Jellyfin users
|
||||||
</button>
|
</button>
|
||||||
@@ -1809,7 +1955,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{showRequestsExtras && sectionGroup.key === 'requests' && (
|
{showRequestsExtras && sectionGroup.key === 'requests-sync' && (
|
||||||
<div className="sync-actions-block">
|
<div className="sync-actions-block">
|
||||||
<div className="sync-actions">
|
<div className="sync-actions">
|
||||||
<button type="button" onClick={syncRequests}>
|
<button type="button" onClick={syncRequests}>
|
||||||
@@ -1826,8 +1972,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
|
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && (
|
||||||
(!settingsSection || isMagentGroupedSection || isSiteGroupedSection) && (
|
|
||||||
<p className="section-subtitle">
|
<p className="section-subtitle">
|
||||||
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
|
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
|
||||||
</p>
|
</p>
|
||||||
@@ -1838,13 +1983,13 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
restarts may still be required before bind/port changes take effect.
|
restarts may still be required before bind/port changes take effect.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{sectionGroup.key === 'sonarr' && sonarrError && (
|
{sectionGroup.key === 'sonarr-library' && sonarrError && (
|
||||||
<div className="error-banner">{sonarrError}</div>
|
<div className="error-banner">{sonarrError}</div>
|
||||||
)}
|
)}
|
||||||
{sectionGroup.key === 'radarr' && radarrError && (
|
{sectionGroup.key === 'radarr-library' && radarrError && (
|
||||||
<div className="error-banner">{radarrError}</div>
|
<div className="error-banner">{radarrError}</div>
|
||||||
)}
|
)}
|
||||||
{sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
|
{sectionGroup.key === 'jellyfin-users' && jellyfinSyncStatus && (
|
||||||
<div className="status-banner">{jellyfinSyncStatus}</div>
|
<div className="status-banner">{jellyfinSyncStatus}</div>
|
||||||
)}
|
)}
|
||||||
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
|
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
|
||||||
@@ -1879,10 +2024,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
|
{showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSyncStatus && (
|
||||||
<div className="status-banner">{requestsSyncStatus}</div>
|
<div className="status-banner">{requestsSyncStatus}</div>
|
||||||
)}
|
)}
|
||||||
{showRequestsExtras && sectionGroup.key === 'requests' && (
|
{showRequestsExtras && sectionGroup.key === 'requests-sync' && (
|
||||||
<div className="status-banner">
|
<div className="status-banner">
|
||||||
Full refresh checks only decide when to run a full refresh. The delta sync interval
|
Full refresh checks only decide when to run a full refresh. The delta sync interval
|
||||||
polls for new or updated requests.
|
polls for new or updated requests.
|
||||||
@@ -1914,7 +2059,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
{artworkPrefetch.message && <div className="meta">{artworkPrefetch.message}</div>}
|
{artworkPrefetch.message && <div className="meta">{artworkPrefetch.message}</div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSync && (
|
{showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSync && (
|
||||||
<div className="sync-progress">
|
<div className="sync-progress">
|
||||||
<div className="sync-meta">
|
<div className="sync-meta">
|
||||||
<span>Status: {requestsSync.status}</span>
|
<span>Status: {requestsSync.status}</span>
|
||||||
|
|||||||
@@ -1126,6 +1126,66 @@ button:disabled,
|
|||||||
gap: 16px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.config-subsection-nav {
|
||||||
|
display: grid;
|
||||||
|
gap: 11px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--ops-line);
|
||||||
|
border-radius: var(--ops-radius-lg);
|
||||||
|
background: rgba(255, 255, 255, 0.024);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-subsection-nav > span {
|
||||||
|
color: var(--ops-muted);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-subsection-nav > div {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-subsection-nav a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 7px 11px;
|
||||||
|
border: 1px solid var(--ops-line-soft);
|
||||||
|
border-radius: var(--ops-radius);
|
||||||
|
background: rgba(255, 255, 255, 0.022);
|
||||||
|
color: var(--ops-text);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-subsection-nav a:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(126, 215, 255, 0.38);
|
||||||
|
background: rgba(14, 165, 233, 0.09);
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-subsection-nav a small {
|
||||||
|
color: var(--ops-cyan);
|
||||||
|
font-family: "JetBrains Mono", Consolas, monospace;
|
||||||
|
font-size: 0.64rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-subsection {
|
||||||
|
scroll-margin-top: 124px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.config-subsection-heading {
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.fleet-status-panel {
|
.fleet-status-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|||||||
@@ -4,18 +4,17 @@ import { usePathname } from 'next/navigation'
|
|||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
{
|
{
|
||||||
title: 'Operations',
|
title: 'Configuration',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin', label: 'Overview' },
|
{ href: '/admin', label: 'Config overview' },
|
||||||
{ href: '/admin/diagnostics', label: 'System health' },
|
{ href: '/admin/general', label: 'Application & proxy' },
|
||||||
{ href: '/new-requests', label: 'New requests' },
|
{ href: '/admin/site', label: 'Site & login' },
|
||||||
{ href: '/admin/issues', label: 'Issue tracking' },
|
{ href: '/admin/notifications', label: 'Notifications' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Services',
|
title: 'Media Services',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/general', label: 'General' },
|
|
||||||
{ href: '/admin/seerr', label: 'Seerr' },
|
{ href: '/admin/seerr', label: 'Seerr' },
|
||||||
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
||||||
{ href: '/admin/sonarr', label: 'Sonarr' },
|
{ href: '/admin/sonarr', label: 'Sonarr' },
|
||||||
@@ -25,24 +24,28 @@ const NAV_GROUPS = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Requests',
|
title: 'Request Pipeline',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/requests', label: 'Request sync' },
|
{ href: '/admin/requests', label: 'Sync & retention' },
|
||||||
{ href: '/admin/requests-all', label: 'All requests' },
|
{ href: '/admin/cache', label: 'Request cache' },
|
||||||
{ href: '/admin/cache', label: 'Cache Control' },
|
|
||||||
{ href: '/admin/artwork', label: 'Artwork cache' },
|
{ href: '/admin/artwork', label: 'Artwork cache' },
|
||||||
|
{ href: '/admin/requests-all', label: 'All requests' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Admin',
|
title: 'Users & Access',
|
||||||
items: [
|
items: [
|
||||||
{ href: '/admin/notifications', label: 'Notifications' },
|
|
||||||
{ href: '/admin/system', label: 'How it works' },
|
|
||||||
{ href: '/admin/site', label: 'Site' },
|
|
||||||
{ href: '/users', label: 'Users' },
|
{ href: '/users', label: 'Users' },
|
||||||
{ href: '/admin/invites', label: 'Invite management' },
|
{ href: '/admin/invites', label: 'Invite management' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'System',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin/diagnostics', label: 'System health' },
|
||||||
{ href: '/admin/logs', label: 'Activity log' },
|
{ href: '/admin/logs', label: 'Activity log' },
|
||||||
{ href: '/admin/maintenance', label: 'Maintenance' },
|
{ href: '/admin/maintenance', label: 'Maintenance' },
|
||||||
|
{ href: '/admin/system', label: 'How it works' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -59,7 +62,9 @@ export default function AdminSidebar() {
|
|||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname === item.href ||
|
pathname === item.href ||
|
||||||
(item.href !== '/' && pathname.startsWith(item.href))
|
(item.href !== '/' &&
|
||||||
|
item.href !== '/admin' &&
|
||||||
|
pathname.startsWith(`${item.href}/`))
|
||||||
return (
|
return (
|
||||||
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
|
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
Reference in New Issue
Block a user