Reorganize admin configuration sections
This commit is contained in:
+267
-122
@@ -33,14 +33,15 @@ const SECTION_LABELS: Record<string, string> = {
|
||||
jellyseerr: 'Seerr',
|
||||
jellyfin: 'Jellyfin',
|
||||
artwork: 'Artwork cache',
|
||||
cache: 'Cache Control',
|
||||
cache: 'Request cache',
|
||||
sonarr: 'Sonarr',
|
||||
radarr: 'Radarr',
|
||||
prowlarr: 'Prowlarr',
|
||||
qbittorrent: 'qBittorrent',
|
||||
log: 'Activity log',
|
||||
requests: 'Request sync',
|
||||
site: 'Site',
|
||||
logs: 'Activity log',
|
||||
maintenance: 'Maintenance',
|
||||
requests: 'Request pipeline',
|
||||
site: 'Site & login',
|
||||
}
|
||||
|
||||
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.',
|
||||
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
|
||||
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.',
|
||||
}
|
||||
|
||||
@@ -233,10 +235,14 @@ const MAGENT_SECTION_GROUPS: Array<{
|
||||
'magent_notify_push_token',
|
||||
'magent_notify_push_user_key',
|
||||
'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>> = {
|
||||
@@ -247,6 +253,7 @@ const MAGENT_GROUPS_BY_SECTION: Record<string, Set<string>> = {
|
||||
'magent-notify-discord',
|
||||
'magent-notify-telegram',
|
||||
'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> = {
|
||||
jellyseerr_base_url: 'Seerr base URL',
|
||||
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_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',
|
||||
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',
|
||||
@@ -390,12 +548,12 @@ type SectionFeedback = {
|
||||
}
|
||||
|
||||
const SERVICE_TEST_ENDPOINTS: Record<string, string> = {
|
||||
jellyseerr: 'seerr',
|
||||
jellyfin: 'jellyfin',
|
||||
sonarr: 'sonarr',
|
||||
radarr: 'radarr',
|
||||
prowlarr: 'prowlarr',
|
||||
qbittorrent: 'qbittorrent',
|
||||
'seerr-connection': 'seerr',
|
||||
'jellyfin-connection': 'jellyfin',
|
||||
'sonarr-connection': 'sonarr',
|
||||
'radarr-connection': 'radarr',
|
||||
'prowlarr-connection': 'prowlarr',
|
||||
'qbittorrent-connection': 'qbittorrent',
|
||||
}
|
||||
|
||||
export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
@@ -633,8 +791,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
: null
|
||||
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
||||
const isSiteGroupedSection = section === 'site'
|
||||
const visibleSections = settingsSection ? [settingsSection] : []
|
||||
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'])
|
||||
@@ -643,38 +801,59 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
'sonarr_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 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: '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
|
||||
? (() => {
|
||||
@@ -682,72 +861,31 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
return []
|
||||
}
|
||||
const magentItems = groupedSettings.magent ?? []
|
||||
const byKey = new Map(magentItems.map((item) => [item.key, item]))
|
||||
const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set<string>()
|
||||
const groups: SettingsSectionGroup[] = MAGENT_SECTION_GROUPS.filter((group) =>
|
||||
allowedGroupKeys.has(group.key),
|
||||
).map((group) => {
|
||||
const items = group.keys
|
||||
.map((key) => byKey.get(key))
|
||||
.filter((item): item is AdminSetting => Boolean(item))
|
||||
return {
|
||||
key: group.key,
|
||||
title: group.title,
|
||||
description: group.description,
|
||||
items,
|
||||
}
|
||||
})
|
||||
return groups
|
||||
const definitions = MAGENT_SECTION_GROUPS.filter((group) => allowedGroupKeys.has(group.key))
|
||||
return buildDefinedSections(definitions, magentItems, false)
|
||||
})()
|
||||
: isSiteGroupedSection
|
||||
? (() => {
|
||||
const siteItems = groupedSettings.site ?? []
|
||||
const byKey = new Map(siteItems.map((item) => [item.key, item]))
|
||||
return SITE_SECTION_GROUPS.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,
|
||||
}
|
||||
})
|
||||
})()
|
||||
: 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
|
||||
})(),
|
||||
}))
|
||||
? 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 === 'cache'
|
||||
const showArtworkExtras = section === 'artwork'
|
||||
const showCacheExtras = section === 'cache'
|
||||
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
|
||||
if (sectionGroup.items && sectionGroup.items.length > 0) return true
|
||||
if (showArtworkExtras && sectionGroup.key === 'artwork') return true
|
||||
if (showCacheExtras && sectionGroup.key === 'cache') return true
|
||||
if (showRequestsExtras && sectionGroup.key === 'requests') return true
|
||||
if (showRequestsExtras && sectionGroup.key === 'requests-sync') return true
|
||||
return false
|
||||
}
|
||||
const renderedSettingsSections = settingsSections.filter(shouldRenderSection)
|
||||
|
||||
useEffect(() => {
|
||||
requestsSyncRef.current = requestsSync
|
||||
@@ -1632,10 +1770,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
<span>Maintenance job</span>
|
||||
<strong>{maintenanceBusy ? 'Running' : 'Idle'}</strong>
|
||||
</div>
|
||||
<div className="cache-rail-metric">
|
||||
<span>Live updates</span>
|
||||
<strong>{liveStreamConnected ? 'Connected' : 'Polling'}</strong>
|
||||
</div>
|
||||
<div className="cache-rail-metric">
|
||||
<span>Log lines in view</span>
|
||||
<strong>{logsLines.length}</strong>
|
||||
@@ -1649,7 +1783,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</div>
|
||||
) : undefined
|
||||
const cacheRail = showCacheExtras ? (
|
||||
<div className="admin-rail-stack">
|
||||
<div className="admin-rail-card cache-rail-card">
|
||||
<span className="admin-rail-eyebrow">Cache control</span>
|
||||
<h2>Saved requests</h2>
|
||||
@@ -1696,6 +1829,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</button>
|
||||
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
|
||||
</div>
|
||||
) : undefined
|
||||
const artworkRail = showArtworkExtras ? (
|
||||
<div className="admin-rail-card cache-rail-card">
|
||||
<span className="admin-rail-eyebrow">Artwork</span>
|
||||
<h2>Cache stats</h2>
|
||||
@@ -1718,7 +1853,6 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : undefined
|
||||
|
||||
if (loading) {
|
||||
@@ -1729,7 +1863,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
<AdminShell
|
||||
title={SECTION_LABELS[section] ?? 'Settings'}
|
||||
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
|
||||
rail={maintenanceRail ?? cacheRail}
|
||||
rail={maintenanceRail ?? cacheRail ?? artworkRail}
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/admin')}>
|
||||
Back to settings
|
||||
@@ -1770,27 +1904,39 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</div>
|
||||
</section>
|
||||
) : 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">
|
||||
{settingsSections
|
||||
.filter(shouldRenderSection)
|
||||
.map((sectionGroup) => (
|
||||
<section key={sectionGroup.key} className="admin-section admin-zone">
|
||||
{renderedSettingsSections.map((sectionGroup, sectionIndex) => (
|
||||
<section id={`config-${sectionGroup.key}`} key={sectionGroup.key} className="admin-section admin-zone config-subsection">
|
||||
<div className="section-header">
|
||||
<h2>
|
||||
{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
|
||||
</h2>
|
||||
{sectionGroup.key === 'sonarr' && (
|
||||
<div className="config-subsection-heading">
|
||||
<span className="section-kicker">Subsection {String(sectionIndex + 1).padStart(2, '0')}</span>
|
||||
<h2>{sectionGroup.title}</h2>
|
||||
</div>
|
||||
{sectionGroup.key === 'sonarr-library' && (
|
||||
<button type="button" onClick={() => loadOptions('sonarr')}>
|
||||
Refresh Sonarr options
|
||||
</button>
|
||||
)}
|
||||
{sectionGroup.key === 'radarr' && (
|
||||
{sectionGroup.key === 'radarr-library' && (
|
||||
<button type="button" onClick={() => loadOptions('radarr')}>
|
||||
Refresh Radarr options
|
||||
</button>
|
||||
)}
|
||||
{sectionGroup.key === 'jellyfin' && (
|
||||
{sectionGroup.key === 'jellyfin-users' && (
|
||||
<button type="button" onClick={syncJellyfinUsers}>
|
||||
Import Jellyfin users
|
||||
</button>
|
||||
@@ -1809,7 +1955,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{showRequestsExtras && sectionGroup.key === 'requests' && (
|
||||
{showRequestsExtras && sectionGroup.key === 'requests-sync' && (
|
||||
<div className="sync-actions-block">
|
||||
<div className="sync-actions">
|
||||
<button type="button" onClick={syncRequests}>
|
||||
@@ -1826,8 +1972,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) &&
|
||||
(!settingsSection || isMagentGroupedSection || isSiteGroupedSection) && (
|
||||
{(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && (
|
||||
<p className="section-subtitle">
|
||||
{sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]}
|
||||
</p>
|
||||
@@ -1838,13 +1983,13 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
restarts may still be required before bind/port changes take effect.
|
||||
</div>
|
||||
)}
|
||||
{sectionGroup.key === 'sonarr' && sonarrError && (
|
||||
{sectionGroup.key === 'sonarr-library' && sonarrError && (
|
||||
<div className="error-banner">{sonarrError}</div>
|
||||
)}
|
||||
{sectionGroup.key === 'radarr' && radarrError && (
|
||||
{sectionGroup.key === 'radarr-library' && radarrError && (
|
||||
<div className="error-banner">{radarrError}</div>
|
||||
)}
|
||||
{sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
|
||||
{sectionGroup.key === 'jellyfin-users' && jellyfinSyncStatus && (
|
||||
<div className="status-banner">{jellyfinSyncStatus}</div>
|
||||
)}
|
||||
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
|
||||
@@ -1879,10 +2024,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
|
||||
{showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSyncStatus && (
|
||||
<div className="status-banner">{requestsSyncStatus}</div>
|
||||
)}
|
||||
{showRequestsExtras && sectionGroup.key === 'requests' && (
|
||||
{showRequestsExtras && sectionGroup.key === 'requests-sync' && (
|
||||
<div className="status-banner">
|
||||
Full refresh checks only decide when to run a full refresh. The delta sync interval
|
||||
polls for new or updated requests.
|
||||
@@ -1914,7 +2059,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
{artworkPrefetch.message && <div className="meta">{artworkPrefetch.message}</div>}
|
||||
</div>
|
||||
)}
|
||||
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSync && (
|
||||
{showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSync && (
|
||||
<div className="sync-progress">
|
||||
<div className="sync-meta">
|
||||
<span>Status: {requestsSync.status}</span>
|
||||
|
||||
@@ -1126,6 +1126,66 @@ button:disabled,
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
|
||||
@@ -4,18 +4,17 @@ import { usePathname } from 'next/navigation'
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{
|
||||
title: 'Operations',
|
||||
title: 'Configuration',
|
||||
items: [
|
||||
{ href: '/admin', label: 'Overview' },
|
||||
{ href: '/admin/diagnostics', label: 'System health' },
|
||||
{ href: '/new-requests', label: 'New requests' },
|
||||
{ href: '/admin/issues', label: 'Issue tracking' },
|
||||
{ href: '/admin', label: 'Config overview' },
|
||||
{ href: '/admin/general', label: 'Application & proxy' },
|
||||
{ href: '/admin/site', label: 'Site & login' },
|
||||
{ href: '/admin/notifications', label: 'Notifications' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Services',
|
||||
title: 'Media Services',
|
||||
items: [
|
||||
{ href: '/admin/general', label: 'General' },
|
||||
{ href: '/admin/seerr', label: 'Seerr' },
|
||||
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
||||
{ href: '/admin/sonarr', label: 'Sonarr' },
|
||||
@@ -25,24 +24,28 @@ const NAV_GROUPS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Requests',
|
||||
title: 'Request Pipeline',
|
||||
items: [
|
||||
{ href: '/admin/requests', label: 'Request sync' },
|
||||
{ href: '/admin/requests-all', label: 'All requests' },
|
||||
{ href: '/admin/cache', label: 'Cache Control' },
|
||||
{ href: '/admin/requests', label: 'Sync & retention' },
|
||||
{ href: '/admin/cache', label: 'Request cache' },
|
||||
{ href: '/admin/artwork', label: 'Artwork cache' },
|
||||
{ href: '/admin/requests-all', label: 'All requests' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Admin',
|
||||
title: 'Users & Access',
|
||||
items: [
|
||||
{ href: '/admin/notifications', label: 'Notifications' },
|
||||
{ href: '/admin/system', label: 'How it works' },
|
||||
{ href: '/admin/site', label: 'Site' },
|
||||
{ href: '/users', label: 'Users' },
|
||||
{ href: '/admin/invites', label: 'Invite management' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ href: '/admin/diagnostics', label: 'System health' },
|
||||
{ href: '/admin/logs', label: 'Activity log' },
|
||||
{ href: '/admin/maintenance', label: 'Maintenance' },
|
||||
{ href: '/admin/system', label: 'How it works' },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -59,7 +62,9 @@ export default function AdminSidebar() {
|
||||
{group.items.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== '/' && pathname.startsWith(item.href))
|
||||
(item.href !== '/' &&
|
||||
item.href !== '/admin' &&
|
||||
pathname.startsWith(`${item.href}/`))
|
||||
return (
|
||||
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
|
||||
{item.label}
|
||||
|
||||
Reference in New Issue
Block a user