diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index e795bd4..16abf91 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -33,14 +33,15 @@ const SECTION_LABELS: Record = { 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 = { 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> = { @@ -247,6 +253,7 @@ const MAGENT_GROUPS_BY_SECTION: Record> = { '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 = { jellyseerr_base_url: 'Seerr base URL', jellyseerr_api_key: 'Seerr API key', @@ -322,6 +453,33 @@ const SETTING_LABEL_OVERRIDES: Record = { 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 = { - 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,111 +801,91 @@ 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 ? (() => { if (section === 'magent') { return [] } const magentItems = groupedSettings.magent ?? [] - const byKey = new Map(magentItems.map((item) => [item.key, item])) const allowedGroupKeys = MAGENT_GROUPS_BY_SECTION[section] ?? new Set() - 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) { Maintenance job {maintenanceBusy ? 'Running' : 'Idle'} -
- Live updates - {liveStreamConnected ? 'Connected' : 'Polling'} -
Log lines in view {logsLines.length} @@ -1649,8 +1783,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
) : undefined const cacheRail = showCacheExtras ? ( -
-
+
Cache control

Saved requests

Load and inspect cached request entries from the right rail.

@@ -1695,8 +1828,10 @@ export default function SettingsPage({ section }: SettingsPageProps) { )} {cacheStatus &&
{cacheStatus}
} -
-
+
+ ) : undefined + const artworkRail = showArtworkExtras ? ( +
Artwork

Cache stats

@@ -1717,7 +1852,6 @@ export default function SettingsPage({ section }: SettingsPageProps) { {artworkSummary?.cache_mode ?? '--'}
-
) : undefined @@ -1729,7 +1863,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { router.push('/admin')}> Back to settings @@ -1770,27 +1904,39 @@ export default function SettingsPage({ section }: SettingsPageProps) { ) : null} - {settingsSections.length > 0 ? ( + {renderedSettingsSections.length > 1 ? ( + + ) : null} + {renderedSettingsSections.length > 0 ? (
- {settingsSections - .filter(shouldRenderSection) - .map((sectionGroup) => ( -
+ {renderedSettingsSections.map((sectionGroup, sectionIndex) => ( +
-

- {sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title} -

- {sectionGroup.key === 'sonarr' && ( +
+ Subsection {String(sectionIndex + 1).padStart(2, '0')} +

{sectionGroup.title}

+
+ {sectionGroup.key === 'sonarr-library' && ( )} - {sectionGroup.key === 'radarr' && ( + {sectionGroup.key === 'radarr-library' && ( )} - {sectionGroup.key === 'jellyfin' && ( + {sectionGroup.key === 'jellyfin-users' && ( @@ -1809,7 +1955,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
) : null} - {showRequestsExtras && sectionGroup.key === 'requests' && ( + {showRequestsExtras && sectionGroup.key === 'requests-sync' && (
)}
- {(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && - (!settingsSection || isMagentGroupedSection || isSiteGroupedSection) && ( -

- {sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]} -

- )} + {(sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]) && ( +

+ {sectionGroup.description || SECTION_DESCRIPTIONS[sectionGroup.key]} +

+ )} {section === 'general' && sectionGroup.key === 'magent-runtime' && (
Runtime host/port and SSL values are configuration settings. Container/process restarts may still be required before bind/port changes take effect.
)} - {sectionGroup.key === 'sonarr' && sonarrError && ( + {sectionGroup.key === 'sonarr-library' && sonarrError && (
{sonarrError}
)} - {sectionGroup.key === 'radarr' && radarrError && ( + {sectionGroup.key === 'radarr-library' && radarrError && (
{radarrError}
)} - {sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && ( + {sectionGroup.key === 'jellyfin-users' && jellyfinSyncStatus && (
{jellyfinSyncStatus}
)} {showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && ( @@ -1879,10 +2024,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
)} - {showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && ( + {showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSyncStatus && (
{requestsSyncStatus}
)} - {showRequestsExtras && sectionGroup.key === 'requests' && ( + {showRequestsExtras && sectionGroup.key === 'requests-sync' && (
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 &&
{artworkPrefetch.message}
}
)} - {showRequestsExtras && sectionGroup.key === 'requests' && requestsSync && ( + {showRequestsExtras && sectionGroup.key === 'requests-sync' && requestsSync && (
Status: {requestsSync.status} diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index e9c722b..31ffd8b 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -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; diff --git a/frontend/app/ui/AdminSidebar.tsx b/frontend/app/ui/AdminSidebar.tsx index b4cdeb0..fc23d6c 100644 --- a/frontend/app/ui/AdminSidebar.tsx +++ b/frontend/app/ui/AdminSidebar.tsx @@ -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 ( {item.label}