diff --git a/backend/app/routers/events.py b/backend/app/routers/events.py index d90a601..881c48e 100644 --- a/backend/app/routers/events.py +++ b/backend/app/routers/events.py @@ -11,7 +11,6 @@ from fastapi.responses import StreamingResponse from ..auth import get_current_user_event_stream from . import requests as requests_router -from .status import services_status router = APIRouter(prefix="/events", tags=["events"]) @@ -85,9 +84,7 @@ async def events_stream( async def event_generator(): yield "retry: 2000\n\n" last_recent_signature: Optional[str] = None - last_services_signature: Optional[str] = None next_recent_at = 0.0 - next_services_at = 0.0 heartbeat_counter = 0 while True: @@ -129,27 +126,6 @@ async def events_stream( yield _sse_json(payload) sent_any = True - if now >= next_services_at: - next_services_at = now + 30.0 - try: - status_payload = await services_status() - payload = { - "type": "home_services", - "ts": datetime.now(timezone.utc).isoformat(), - "status": status_payload, - } - except Exception as exc: - payload = { - "type": "home_services", - "ts": datetime.now(timezone.utc).isoformat(), - "error": str(exc), - } - signature = json.dumps(payload, ensure_ascii=True, separators=(",", ":"), default=str) - if signature != last_services_signature: - last_services_signature = signature - yield _sse_json(payload) - sent_any = True - if sent_any: heartbeat_counter = 0 else: diff --git a/backend/app/routers/status.py b/backend/app/routers/status.py index 3d38730..f5d2721 100644 --- a/backend/app/routers/status.py +++ b/backend/app/routers/status.py @@ -2,7 +2,7 @@ from typing import Any, Dict import httpx from fastapi import APIRouter, Depends, HTTPException -from ..auth import get_current_user +from ..auth import require_admin from ..runtime import get_runtime_settings from ..clients.jellyseerr import JellyseerrClient from ..clients.sonarr import SonarrClient @@ -11,7 +11,7 @@ from ..clients.prowlarr import ProwlarrClient from ..clients.qbittorrent import QBittorrentClient from ..clients.jellyfin import JellyfinClient -router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(get_current_user)]) +router = APIRouter(prefix="/status", tags=["status"], dependencies=[Depends(require_admin)]) async def _check(name: str, configured: bool, func) -> Dict[str, Any]: diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 79eddaa..5e2156d 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -9,6 +9,7 @@ from fastapi import HTTPException from starlette.requests import Request from backend.app import db +from backend.app.auth import require_admin from backend.app.config import settings from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url from backend.app.models import NormalizedState, RequestType, Snapshot, TimelineHop @@ -102,6 +103,11 @@ class NetworkSecurityTests(unittest.TestCase): class ServiceStatusTests(unittest.IsolatedAsyncioTestCase): + def test_status_router_requires_admin(self) -> None: + dependencies = [getattr(dependency, "dependency", None) for dependency in status_router.router.dependencies] + + self.assertIn(require_admin, dependencies) + async def test_qbittorrent_login_accepts_modern_empty_response_with_session_cookie(self) -> None: class FakeClient: def __init__(self) -> None: diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 940fe94..6fa7a90 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -106,13 +106,13 @@ const SECTION_DESCRIPTIONS: Record = { 'Notification providers and delivery channel settings used by Magent messaging features.', seerr: 'Connect Seerr where users submit content requests.', jellyseerr: 'Connect Seerr where users submit content requests.', - jellyfin: 'Control Jellyfin login and availability checks.', + jellyfin: 'Jellyfin connection, public playback links, user sync, and availability checks.', artwork: 'Cache posters/backdrops and review artwork coverage.', cache: 'Manage saved requests cache and refresh behavior.', - sonarr: 'TV automation settings.', - radarr: 'Movie automation settings.', - prowlarr: 'Indexer search settings.', - qbittorrent: 'Downloader connection settings.', + 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.', + 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.', site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.', @@ -639,6 +639,10 @@ export default function SettingsPage({ section }: SettingsPageProps) { const artworkSettingKeys = new Set(['artwork_cache_mode']) const generatedSettingKeys = new Set(['site_changelog']) const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys]) + const obsoleteSettingKeys = new Set([ + 'sonarr_qbittorrent_category', + 'radarr_qbittorrent_category', + ]) const requestSettingOrder = [ 'requests_poll_interval_seconds', 'requests_delta_sync_interval_minutes', @@ -716,10 +720,13 @@ export default function SettingsPage({ section }: SettingsPageProps) { title: SECTION_LABELS[sectionKey] ?? sectionKey, items: (() => { const sectionItems = groupedSettings[sectionKey] ?? [] - const filtered = - sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site' - ? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key)) - : sectionItems + 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) } @@ -824,12 +831,10 @@ export default function SettingsPage({ section }: SettingsPageProps) { sonarr_api_key: 'API key for Sonarr.', sonarr_quality_profile_id: 'Quality profile used when adding TV shows.', sonarr_root_folder: 'Root folder where Sonarr stores TV shows.', - sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.', radarr_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.', radarr_api_key: 'API key for Radarr.', radarr_quality_profile_id: 'Quality profile used when adding movies.', radarr_root_folder: 'Root folder where Radarr stores movies.', - radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.', prowlarr_base_url: 'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.', prowlarr_api_key: 'API key for Prowlarr.', @@ -2398,7 +2403,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { onClick={() => void saveSettingGroup(sectionGroup)} disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]} > - {sectionSaving[sectionGroup.key] ? 'Saving...' : 'Save section'} + {sectionSaving[sectionGroup.key] ? 'Saving...' : `Save ${sectionGroup.title}`} diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index 932d298..437a2ff 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -57,6 +57,9 @@ export default function AdminLandingPage() { const [portalOverview, setPortalOverview] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) + const [serviceTesting, setServiceTesting] = useState>({}) + const [serviceTestResults, setServiceTestResults] = useState>({}) + const [serviceCheckedAt, setServiceCheckedAt] = useState(null) useEffect(() => { if (!getToken()) { @@ -95,6 +98,7 @@ export default function AdminLandingPage() { const data = await serviceResponse.json() setServiceOverall(data?.overall ?? 'unknown') setServices(Array.isArray(data?.services) ? data.services : []) + setServiceCheckedAt(new Date().toISOString()) } if (recentResponse.ok) { @@ -115,8 +119,58 @@ export default function AdminLandingPage() { } void load() + + const refreshTimer = window.setInterval(async () => { + try { + const response = await authFetch(`${getApiBase()}/status/services`) + if (!response.ok) return + const data = await response.json() + setServiceOverall(data?.overall ?? 'unknown') + setServices(Array.isArray(data?.services) ? data.services : []) + setServiceCheckedAt(new Date().toISOString()) + } catch (err) { + console.error(err) + } + }, 30_000) + + return () => window.clearInterval(refreshTimer) }, [router]) + const testService = async (service: ServiceState) => { + const slug = service.name.toLowerCase().replace(/[^a-z0-9]/g, '') + setServiceTesting((current) => ({ ...current, [service.name]: true })) + setServiceTestResults((current) => { + const next = { ...current } + delete next[service.name] + return next + }) + try { + const response = await authFetch(`${getApiBase()}/status/services/${slug}/test`, { + method: 'POST', + }) + if (!response.ok) { + const text = await response.text() + throw new Error(text || `Service test failed: ${response.status}`) + } + const result = await response.json() + setServices((current) => current.map((item) => + item.name === service.name + ? { ...item, status: result?.status ?? item.status, message: result?.message ?? item.message } + : item + )) + setServiceTestResults((current) => ({ + ...current, + [service.name]: result?.message || (result?.status === 'up' ? 'Connection test passed.' : 'Connection test completed.'), + })) + setServiceCheckedAt(new Date().toISOString()) + } catch (err) { + console.error(err) + setServiceTestResults((current) => ({ ...current, [service.name]: 'Connection test failed.' })) + } finally { + setServiceTesting((current) => ({ ...current, [service.name]: false })) + } + } + const serviceCounts = useMemo(() => { const up = services.filter((service) => service.status === 'up').length const down = services.filter((service) => service.status === 'down').length @@ -132,27 +186,14 @@ export default function AdminLandingPage() { const rail = (
- Service ecosystem -
- {services.length === 0 ? ( -
Service status is not available yet.
- ) : ( - services.map((service) => ( - - - - {service.name} - {service.message ?? 'No message reported'} - - {service.status} - - )) - )} -
+ Fleet summary +

{serviceCounts.up} of {serviceCounts.total || 0} online

+

+ {serviceCounts.down + serviceCounts.degraded > 0 + ? `${serviceCounts.down + serviceCounts.degraded} configured service${serviceCounts.down + serviceCounts.degraded === 1 ? '' : 's'} need attention.` + : 'No configured service is currently reporting a fault.'} +

+ Open full diagnostics
Quick actions @@ -168,12 +209,12 @@ export default function AdminLandingPage() { return ( router.push('/')}> - View health + } > @@ -205,6 +246,52 @@ export default function AdminLandingPage() {
+
+
+
+ Fleet service mesh +

System status

+

+ Admin-only connectivity status for the services used by Magent. + {serviceCheckedAt ? ` Last checked ${formatDateTime(serviceCheckedAt)}.` : ''} +

+
+ + {serviceOverall.replaceAll('_', ' ')} + +
+ {services.length === 0 ? ( +
Service status is not available yet.
+ ) : ( +
+ {services.map((service) => { + const slug = service.name.toLowerCase().replace(/[^a-z0-9]/g, '') + const testing = Boolean(serviceTesting[service.name]) + return ( +
+
+
+

{serviceTestResults[service.name] ?? service.message ?? 'No recent detail was returned.'}

+
+ Configure + +
+
+ ) + })} +
+ )} +
+
diff --git a/frontend/app/admin/system/page.tsx b/frontend/app/admin/system/page.tsx index 7a74425..9d75e81 100644 --- a/frontend/app/admin/system/page.tsx +++ b/frontend/app/admin/system/page.tsx @@ -286,7 +286,7 @@ export default function AdminSystemGuidePage() {

Landing page

-

Recent requests and service summaries refresh live for signed-in users.

+

Recent request activity refreshes live for signed-in users.

Request pages

@@ -294,7 +294,7 @@ export default function AdminSystemGuidePage() {

Admin views

-

Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.

+

Fleet health, diagnostics, logs, sync state, and maintenance data stay in admin-only settings.

diff --git a/frontend/app/ops-redesign.css b/frontend/app/ops-redesign.css index 793438a..e774112 100644 --- a/frontend/app/ops-redesign.css +++ b/frontend/app/ops-redesign.css @@ -574,6 +574,215 @@ button:disabled, color: var(--ops-muted); } +.home-page { + display: grid; + gap: 18px; +} + +.home-command { + display: grid; + grid-template-columns: minmax(0, 0.85fr) minmax(380px, 1.15fr); + align-items: end; + gap: clamp(24px, 5vw, 64px); + padding: clamp(24px, 4vw, 42px); + overflow: hidden; + border: 1px solid rgba(126, 215, 255, 0.22); + border-radius: var(--ops-radius-lg); + background: + radial-gradient(circle at 88% 20%, rgba(14, 165, 233, 0.18), transparent 42%), + linear-gradient(145deg, rgba(35, 74, 145, 0.18), rgba(9, 17, 36, 0.68)); +} + +.home-command-copy { + display: grid; + gap: 10px; +} + +.home-command-copy h1 { + margin: 0; + font-size: clamp(2rem, 4.5vw, 3.8rem); + line-height: 0.98; + letter-spacing: -0.045em; +} + +.home-command-copy p { + max-width: 52ch; + margin: 0; + color: var(--ops-muted); + line-height: 1.6; +} + +.home-search { + display: grid; + gap: 9px; +} + +.home-search > label { + color: var(--ops-muted); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.home-search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; +} + +.home-search-row input, +.home-search-row button { + min-height: 52px; +} + +.home-search-row input { + padding-inline: 17px; + border-color: rgba(126, 215, 255, 0.25); + background: rgba(3, 8, 20, 0.48); + font-size: 1rem; +} + +.home-search-results, +.home-recent { + padding: 20px; + border: 1px solid var(--ops-line); + border-radius: var(--ops-radius-lg); + background: rgba(255, 255, 255, 0.024); +} + +.home-section-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 18px; +} + +.home-section-heading > div:first-child { + display: grid; + gap: 5px; +} + +.home-section-heading h2 { + margin: 0; +} + +.home-result-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 10px; + margin-top: 16px; +} + +.home-result-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + min-height: 72px; + padding: 13px 15px; + text-align: left; + border: 1px solid var(--ops-line-soft); + background: rgba(255, 255, 255, 0.032); +} + +.home-result-card > span:first-child { + display: grid; + gap: 4px; + min-width: 0; +} + +.home-result-card small, +.home-result-card > span:last-child { + color: var(--ops-muted); + font-size: 0.75rem; +} + +.home-metric-strip { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + border: 1px solid var(--ops-line); + border-radius: var(--ops-radius-lg); + background: rgba(255, 255, 255, 0.02); +} + +.home-metric-strip > div { + display: grid; + gap: 6px; + min-width: 0; + padding: 15px 18px; + border-right: 1px solid var(--ops-line-soft); +} + +.home-metric-strip > div:last-child { + border-right: 0; +} + +.home-metric-strip span { + color: var(--ops-muted); + font-size: 0.74rem; +} + +.home-metric-strip strong { + color: var(--ops-text); + font-size: 1.15rem; +} + +.home-metric-strip strong.is-live { + color: var(--ops-green); +} + +.home-recent .recent-header { + margin-bottom: 16px; +} + +.home-recent-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.home-recent-grid .recent-card { + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + min-height: 92px; + padding: 10px; +} + +.recent-poster-placeholder { + display: grid; + place-items: center; + width: 52px; + height: 70px; + color: var(--ops-faint); + background: rgba(255, 255, 255, 0.035); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.68rem; +} + +.recent-open-cue { + padding-right: 6px; + color: var(--ops-cyan); + font-family: "JetBrains Mono", Consolas, monospace; + font-size: 0.66rem; + font-weight: 700; + text-transform: uppercase; +} + +.home-empty-state { + display: grid; + gap: 6px; + place-items: center; + min-height: 170px; + color: var(--ops-muted); + text-align: center; + border: 1px dashed var(--ops-line); + border-radius: var(--ops-radius); +} + +.home-empty-state strong { + color: var(--ops-text); +} + .layout-grid { grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr); align-items: start; @@ -921,6 +1130,110 @@ button:disabled, gap: 16px; } +.fleet-status-panel { + display: grid; + gap: 16px; + border-color: rgba(126, 215, 255, 0.2); + background: + radial-gradient(circle at 100% 0%, rgba(14, 165, 233, 0.1), transparent 38%), + rgba(255, 255, 255, 0.024); +} + +.fleet-status-header { + align-items: center; +} + +.fleet-status-header > div { + display: grid; + gap: 5px; +} + +.fleet-status-header h2 { + margin: 0; +} + +.fleet-service-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.fleet-service-card { + display: grid; + gap: 13px; + min-width: 0; + padding: 15px; + border: 1px solid var(--ops-line-soft); + border-radius: var(--ops-radius); + background: rgba(3, 8, 20, 0.24); +} + +.fleet-service-card.system-down { + border-color: rgba(255, 141, 141, 0.3); +} + +.fleet-service-card.system-degraded { + border-color: rgba(255, 208, 130, 0.3); +} + +.fleet-service-title { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 11px; +} + +.fleet-service-title > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + min-width: 0; +} + +.fleet-service-title h3 { + margin: 0; +} + +.fleet-service-card p { + min-height: 2.8em; + margin: 0; + color: var(--ops-muted); + font-size: 0.84rem; + line-height: 1.45; +} + +.fleet-service-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-top: 11px; + border-top: 1px solid var(--ops-line-soft); +} + +.fleet-service-actions a { + color: var(--ops-cyan); + font-size: 0.78rem; + font-weight: 700; + text-decoration: none; +} + +.fleet-service-actions button { + min-height: 34px; + padding: 7px 10px; + font-size: 0.75rem; +} + +.admin-rail-action { + display: inline-flex; + margin-top: 12px; + color: var(--ops-cyan); + font-size: 0.8rem; + font-weight: 700; + text-decoration: none; +} + .admin-table { overflow-x: auto; } @@ -1212,6 +1525,78 @@ button:disabled, border-top: 1px solid var(--ops-line-soft); } +.admin-form .admin-zone { + display: grid; + gap: 14px; + padding: 20px; +} + +.admin-form .admin-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 12px; +} + +.admin-form .admin-grid > label { + align-content: start; + min-width: 0; + padding: 14px; + text-align: left; + border: 1px solid var(--ops-line-soft); + border-radius: var(--ops-radius); + background: rgba(255, 255, 255, 0.022); +} + +.admin-form .admin-grid > label:focus-within { + border-color: rgba(126, 215, 255, 0.38); + background: rgba(14, 165, 233, 0.055); +} + +.admin-form .admin-grid label[data-helper]::after { + min-height: 2.8em; + color: var(--ops-muted); + font-family: Manrope, "Segoe UI", sans-serif; + font-size: 0.76rem; + font-weight: 500; + line-height: 1.4; + text-align: left; + text-transform: none; +} + +.admin-form .label-row { + align-items: flex-start; + gap: 12px; +} + +.admin-form .label-row > span:first-child { + color: var(--ops-text); +} + +.admin-form .label-row .meta { + flex: 0 0 auto; + padding: 3px 6px; + border: 1px solid var(--ops-line-soft); + border-radius: 999px; + color: var(--ops-faint); + font-size: 0.62rem; + line-height: 1.2; + text-transform: none; +} + +.admin-form .admin-grid input, +.admin-form .admin-grid select, +.admin-form .admin-grid textarea { + width: 100%; +} + +.admin-form .section-header h2 { + margin: 0; +} + +.admin-form .settings-section-actions { + align-items: end; + justify-content: flex-end; +} + .service-status-panel { display: grid; grid-template-columns: minmax(0, 0.82fr) minmax(520px, 1fr); @@ -1342,6 +1727,11 @@ button:disabled, .side-panel { position: static; } + + .home-command { + grid-template-columns: 1fr; + align-items: stretch; + } } @media (max-width: 780px) { @@ -1439,6 +1829,9 @@ button:disabled, } .ops-metric-grid, + .home-metric-strip, + .home-recent-grid, + .fleet-service-grid, .portal-overview-grid, .status-box, .history-grid, @@ -1448,6 +1841,38 @@ button:disabled, grid-template-columns: 1fr; } + .home-metric-strip > div { + border-right: 0; + border-bottom: 1px solid var(--ops-line-soft); + } + + .home-metric-strip > div:last-child { + border-bottom: 0; + } + + .home-search-row { + grid-template-columns: 1fr; + } + + .home-command, + .home-search-results, + .home-recent { + padding: 16px; + } + + .home-section-heading { + align-items: stretch; + flex-direction: column; + } + + .home-recent-grid .recent-card { + grid-template-columns: auto minmax(0, 1fr); + } + + .recent-open-cue { + display: none; + } + .search, .portal-discovery-form, .portal-toolbar, diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 6959a11..a2245b8 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -64,13 +64,6 @@ export default function HomePage() { const [recentDays, setRecentDays] = useState(90) const [recentStage, setRecentStage] = useState('all') const [authReady, setAuthReady] = useState(false) - const [servicesStatus, setServicesStatus] = useState< - { overall: string; services: { name: string; status: string; message?: string }[] } | null - >(null) - const [servicesLoading, setServicesLoading] = useState(false) - const [servicesError, setServicesError] = useState(null) - const [serviceTesting, setServiceTesting] = useState>({}) - const [serviceTestResults, setServiceTestResults] = useState>({}) const [liveStreamConnected, setLiveStreamConnected] = useState(false) const submit = (event: React.FormEvent) => { @@ -84,61 +77,6 @@ export default function HomePage() { void runSearch(trimmed) } - const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '') - - const updateServiceStatus = (name: string, status: string, message?: string) => { - setServicesStatus((prev) => { - if (!prev) return prev - return { - ...prev, - services: prev.services.map((service) => - service.name === name ? { ...service, status, message } : service - ), - } - }) - } - - const testService = async (name: string) => { - const slug = toServiceSlug(name) - setServiceTesting((prev) => ({ ...prev, [name]: true })) - setServiceTestResults((prev) => ({ ...prev, [name]: null })) - try { - const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, { - method: 'POST', - }) - if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } - const text = await response.text() - throw new Error(text || `Service test failed: ${response.status}`) - } - const data = await response.json() - const status = data?.status ?? 'unknown' - const message = - data?.message || - (status === 'up' - ? 'API OK' - : status === 'down' - ? 'API unreachable' - : status === 'degraded' - ? 'Health warnings' - : status === 'not_configured' - ? 'Not configured' - : 'Unknown') - setServiceTestResults((prev) => ({ ...prev, [name]: message })) - updateServiceStatus(name, status, data?.message) - } catch (error) { - console.error(error) - setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' })) - } finally { - setServiceTesting((prev) => ({ ...prev, [name]: false })) - } - } - useEffect(() => { if (!getToken()) { router.push('/login') @@ -194,42 +132,6 @@ export default function HomePage() { load() }, [recentDays, recentStage]) - useEffect(() => { - if (!authReady) { - return - } - const load = async () => { - setServicesLoading(true) - setServicesError(null) - try { - const baseUrl = getApiBase() - const response = await authFetch(`${baseUrl}/status/services`) - if (!response.ok) { - if (response.status === 401) { - clearToken() - router.push('/login') - return - } - throw new Error(`Service status failed: ${response.status}`) - } - const data = await response.json() - setServicesStatus(data) - } catch (error) { - console.error(error) - setServicesError('Service status is not available right now.') - } finally { - setServicesLoading(false) - } - } - - void load() - if (liveStreamConnected) { - return - } - const timer = setInterval(load, 30000) - return () => clearInterval(timer) - }, [authReady, liveStreamConnected, router]) - useEffect(() => { if (!authReady) { setLiveStreamConnected(false) @@ -281,16 +183,6 @@ export default function HomePage() { } return } - if (payload.type === 'home_services') { - if (payload.status && typeof payload.status === 'object') { - setServicesStatus(payload.status) - setServicesError(null) - setServicesLoading(false) - } else if (typeof payload.error === 'string' && payload.error.trim()) { - setServicesError('Service status is not available right now.') - setServicesLoading(false) - } - } } catch (error) { console.error(error) } @@ -362,271 +254,157 @@ export default function HomePage() { return date.toLocaleString() } - const serviceItems = servicesStatus?.services ?? [] - const serviceUpCount = serviceItems.filter((service) => service.status === 'up').length - const serviceAttentionCount = serviceItems.filter((service) => - ['down', 'degraded', 'not_configured'].includes(service.status) - ).length - const serviceOverall = servicesStatus?.overall ?? 'unknown' - const serviceStatusLabel = servicesLoading - ? 'Checking services...' - : servicesError - ? 'Status not available yet' - : serviceOverall === 'up' - ? 'Services are up and running' - : serviceOverall === 'down' - ? 'Something is down' - : 'Some services need attention' - const serviceSummary = servicesError - ? 'Unable to load service status' - : serviceItems.length === 0 - ? 'No services reported yet' - : serviceAttentionCount > 0 - ? `${serviceAttentionCount} of ${serviceItems.length} need attention` - : `${serviceUpCount} of ${serviceItems.length} online` - const orderedServices = ['Seerr', 'Sonarr', 'Radarr', 'Prowlarr', 'qBittorrent', 'Jellyfin'].map( - (name) => { - const item = serviceItems.find((entry) => entry.name === name) - return { name, status: item?.status ?? 'unknown', message: item?.message } - } - ) const activeRecentCount = recent.filter((item) => { const label = String(item.statusLabel ?? '').toLowerCase() return !label.includes('ready') && !label.includes('available') && !label.includes('declined') }).length + const readyRecentCount = recent.filter((item) => { + const label = String(item.statusLabel ?? '').toLowerCase() + return label.includes('ready') || label.includes('available') + }).length return ( -
-
-
- Service mesh - - {serviceUpCount}/{serviceItems.length || 0} - -

{servicesLoading ? 'Checking services now.' : 'Configured services online.'}

+
+
+
+ Request lookup +

Find a media request

+

+ Enter a title and year, or jump straight to a request using its request number. +

-
- Attention - {serviceAttentionCount} -

Services reporting down, degraded, or not configured.

+
+ +
+ setQuery(event.target.value)} + placeholder="Dune 2021 or 1289" + /> + +
+
+
+ + {(searchError || searchResults.length > 0) && ( +
+
+
+ Search results +

{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}

+
+ +
+ {searchError ? ( +
{searchError}
+ ) : ( +
+ {searchResults.map((item, index) => ( + + ))} +
+ )} +
+ )} + +
+
In view{recent.length}
+
In progress{activeRecentCount}
+
Ready{readyRecentCount}
+
Live updates{liveStreamConnected ? 'Connected' : 'Reconnecting'}
+
+ +
+
+
+ Request activity +

{role === 'admin' ? 'Recent requests' : 'My recent requests'}

+
+ {authReady && ( +
+ + +
+ )}
-
- Loaded requests - {recent.length} -

Returned by the live request cache.

-
-
- Active queue - {activeRecentCount} -

Loaded requests still moving through the pipeline.

+
+ {recentLoading ? ( +
+ + ) : recentError ? ( +
{recentError}
+ ) : recent.length === 0 ? ( +
+ No requests match these filters + Try a wider period or a different stage. +
+ ) : ( + recent.map((item) => ( + + )) + )}
-
-
-
- - - System status - {serviceSummary} - {serviceStatusLabel} - - - - {servicesLoading ? 'Checking' : serviceOverall.replaceAll('_', ' ')} - - - - -
- {orderedServices.map(({ name, status, message }) => { - const testing = serviceTesting[name] ?? false - return ( -
- -
- {name} - - {serviceTestResults[name] ?? message ?? 'No recent detail'} - -
-
- - {status === 'up' - ? 'Up' - : status === 'down' - ? 'Down' - : status === 'degraded' - ? 'Needs attention' - : status === 'not_configured' - ? 'Not configured' - : 'Unknown'} - - -
-
- ) - })} -
-
-
-

{role === 'admin' ? 'All requests' : 'My recent requests'}

- {authReady && ( -
- - -
- )} -
-
- {recentLoading ? ( -
- - ) : recentError ? ( - - ) : recent.length === 0 ? ( - - ) : ( - recent.map((item) => ( - - )) - )} -
-
- -
) } diff --git a/frontend/app/ui/AdminSidebar.tsx b/frontend/app/ui/AdminSidebar.tsx index 096110c..ba012bd 100644 --- a/frontend/app/ui/AdminSidebar.tsx +++ b/frontend/app/ui/AdminSidebar.tsx @@ -7,7 +7,7 @@ const NAV_GROUPS = [ title: 'Operations', items: [ { href: '/admin', label: 'Overview' }, - { href: '/', label: 'Health' }, + { href: '/admin/diagnostics', label: 'System health' }, { href: '/portal/requests', label: 'Request portal' }, { href: '/admin/issues', label: 'Issue tracking' }, ],