Move fleet health into admin settings and tidy landing page
Magent CI/CD / verify (push) Successful in 10m46s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 18s

This commit is contained in:
2026-08-29 22:53:48 +12:00
parent c073581639
commit 3815dfea60
9 changed files with 706 additions and 429 deletions
-24
View File
@@ -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:
+2 -2
View File
@@ -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]:
+6
View File
@@ -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:
+17 -12
View File
@@ -106,13 +106,13 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
'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}`}
</button>
</div>
</section>
+112 -25
View File
@@ -57,6 +57,9 @@ export default function AdminLandingPage() {
const [portalOverview, setPortalOverview] = useState<PortalOverview | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string>>({})
const [serviceCheckedAt, setServiceCheckedAt] = useState<string | null>(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 = (
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Service ecosystem</span>
<div className="service-ecosystem">
{services.length === 0 ? (
<div className="status-banner">Service status is not available yet.</div>
) : (
services.map((service) => (
<a
key={service.name}
className="service-row"
href={`/admin/${service.name.toLowerCase().replace(/[^a-z0-9]/g, '')}`}
>
<span className={`system-dot system-dot-${service.status}`} />
<span>
<strong>{service.name}</strong>
<small>{service.message ?? 'No message reported'}</small>
</span>
<span className={`small-pill system-pill-${service.status}`}>{service.status}</span>
</a>
))
)}
</div>
<span className="admin-rail-eyebrow">Fleet summary</span>
<h2>{serviceCounts.up} of {serviceCounts.total || 0} online</h2>
<p>
{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.'}
</p>
<a className="admin-rail-action" href="/admin/diagnostics">Open full diagnostics</a>
</div>
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Quick actions</span>
@@ -168,12 +209,12 @@ export default function AdminLandingPage() {
return (
<AdminShell
title="Operations Center"
subtitle="Live Magent controls, request movement, issue intake, and service health."
title="Admin overview"
subtitle="Service health, request movement, issue intake, and the controls that keep Magent running."
rail={rail}
actions={
<button type="button" onClick={() => router.push('/')}>
View health
<button type="button" onClick={() => router.push('/admin/diagnostics')}>
Run diagnostics
</button>
}
>
@@ -205,6 +246,52 @@ export default function AdminLandingPage() {
</div>
</section>
<section className="admin-zone fleet-status-panel">
<div className="section-header fleet-status-header">
<div>
<span className="section-kicker">Fleet service mesh</span>
<h2>System status</h2>
<p className="section-subtitle">
Admin-only connectivity status for the services used by Magent.
{serviceCheckedAt ? ` Last checked ${formatDateTime(serviceCheckedAt)}.` : ''}
</p>
</div>
<span className={`small-pill system-pill-${serviceOverall}`}>
{serviceOverall.replaceAll('_', ' ')}
</span>
</div>
{services.length === 0 ? (
<div className="status-banner">Service status is not available yet.</div>
) : (
<div className="fleet-service-grid">
{services.map((service) => {
const slug = service.name.toLowerCase().replace(/[^a-z0-9]/g, '')
const testing = Boolean(serviceTesting[service.name])
return (
<article className={`fleet-service-card system-${service.status}`} key={service.name}>
<div className="fleet-service-title">
<span className="system-dot" aria-hidden="true" />
<div>
<h3>{service.name}</h3>
<span className={`small-pill system-pill-${service.status}`}>
{service.status.replaceAll('_', ' ')}
</span>
</div>
</div>
<p>{serviceTestResults[service.name] ?? service.message ?? 'No recent detail was returned.'}</p>
<div className="fleet-service-actions">
<a href={`/admin/${slug}`}>Configure</a>
<button type="button" className="ghost-button" disabled={testing} onClick={() => void testService(service)}>
{testing ? 'Testing...' : 'Test connection'}
</button>
</div>
</article>
)
})}
</div>
)}
</section>
<section className="admin-zone">
<div className="section-header">
<div>
+2 -2
View File
@@ -286,7 +286,7 @@ export default function AdminSystemGuidePage() {
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>Landing page</h3>
<p>Recent requests and service summaries refresh live for signed-in users.</p>
<p>Recent request activity refreshes live for signed-in users.</p>
</article>
<article className="system-guide-card">
<h3>Request pages</h3>
@@ -294,7 +294,7 @@ export default function AdminSystemGuidePage() {
</article>
<article className="system-guide-card">
<h3>Admin views</h3>
<p>Diagnostics, logs, sync state, and maintenance surfaces stream live operational data.</p>
<p>Fleet health, diagnostics, logs, sync state, and maintenance data stay in admin-only settings.</p>
</article>
</div>
</div>
+425
View File
@@ -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,
+141 -363
View File
@@ -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<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
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 (
<main className="card">
<section className="ops-metric-grid">
<div className="ops-metric-card">
<span className="section-kicker">Service mesh</span>
<strong>
{serviceUpCount}/{serviceItems.length || 0}
</strong>
<p>{servicesLoading ? 'Checking services now.' : 'Configured services online.'}</p>
<main className="card home-page">
<section className="home-command">
<div className="home-command-copy">
<span className="section-kicker">Request lookup</span>
<h1>Find a media request</h1>
<p>
Enter a title and year, or jump straight to a request using its request number.
</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Attention</span>
<strong>{serviceAttentionCount}</strong>
<p>Services reporting down, degraded, or not configured.</p>
<form onSubmit={submit} className="home-search">
<label htmlFor="request-search">Title, year, or request number</label>
<div className="home-search-row">
<input
id="request-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Dune 2021 or 1289"
/>
<button type="submit">Find request</button>
</div>
</form>
</section>
{(searchError || searchResults.length > 0) && (
<section className="home-search-results" aria-live="polite">
<div className="home-section-heading">
<div>
<span className="section-kicker">Search results</span>
<h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
</div>
<button type="button" className="ghost-button" onClick={() => {
setSearchResults([])
setSearchError(null)
}}>
Clear
</button>
</div>
{searchError ? (
<div className="error-banner">{searchError}</div>
) : (
<div className="home-result-grid">
{searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
className="home-result-card"
disabled={!item.requestId}
onClick={() => item.requestId && router.push(`/requests/${item.requestId}`)}
>
<span>
<strong>{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</strong>
<small>{item.type?.toUpperCase() || 'MEDIA'}</small>
</span>
<span>{!item.requestId ? 'Not requested' : item.statusLabel || 'Already requested'}</span>
</button>
))}
</div>
)}
</section>
)}
<section className="home-metric-strip" aria-label="Request summary">
<div><span>In view</span><strong>{recent.length}</strong></div>
<div><span>In progress</span><strong>{activeRecentCount}</strong></div>
<div><span>Ready</span><strong>{readyRecentCount}</strong></div>
<div><span>Live updates</span><strong className={liveStreamConnected ? 'is-live' : ''}>{liveStreamConnected ? 'Connected' : 'Reconnecting'}</strong></div>
</section>
<section className="recent home-recent">
<div className="recent-header home-section-heading">
<div>
<span className="section-kicker">Request activity</span>
<h2>{role === 'admin' ? 'Recent requests' : 'My recent requests'}</h2>
</div>
{authReady && (
<div className="recent-filter-group">
<label className="recent-filter">
<span>Period</span>
<select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
<option value={0}>All time</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
<label className="recent-filter">
<span>Stage</span>
<select value={recentStage} onChange={(event) => setRecentStage(event.target.value)}>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
</div>
)}
</div>
<div className="ops-metric-card">
<span className="section-kicker">Loaded requests</span>
<strong>{recent.length}</strong>
<p>Returned by the live request cache.</p>
</div>
<div className="ops-metric-card">
<span className="section-kicker">Active queue</span>
<strong>{activeRecentCount}</strong>
<p>Loaded requests still moving through the pipeline.</p>
<div className="recent-grid home-recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests...</span>
</div>
) : recentError ? (
<div className="error-banner">{recentError}</div>
) : recent.length === 0 ? (
<div className="home-empty-state">
<strong>No requests match these filters</strong>
<span>Try a wider period or a different stage.</span>
</div>
) : (
recent.map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/requests/${item.id}`)}
className="recent-card"
>
{item.artwork?.poster_url ? (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt=""
loading="lazy"
/>
) : (
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
)}
<span className="recent-info">
<span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
<span className="recent-meta">
{item.statusLabel || 'Status not available yet'} · Request {item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
</span>
<span className="recent-open-cue" aria-hidden="true">Open</span>
</button>
))
)}
</div>
</section>
<div className="layout-grid">
<section className="recent centerpiece">
<details className="system-status system-status-dropdown">
<summary className="system-summary">
<span className="system-summary-copy">
<span className="section-kicker">System status</span>
<strong>{serviceSummary}</strong>
<span>{serviceStatusLabel}</span>
</span>
<span className="system-summary-actions">
<span className={`system-pill system-pill-${serviceOverall}`}>
{servicesLoading ? 'Checking' : serviceOverall.replaceAll('_', ' ')}
</span>
<span className="system-dropdown-cue" aria-hidden="true">Open</span>
</span>
</summary>
<div className="system-list">
{orderedServices.map(({ name, status, message }) => {
const testing = serviceTesting[name] ?? false
return (
<div key={name} className={`system-item system-${status}`}>
<span className="system-dot" />
<div className="system-meta">
<span className="system-name">{name}</span>
<span className="system-test-message">
{serviceTestResults[name] ?? message ?? 'No recent detail'}
</span>
</div>
<div className="system-actions">
<span className="system-state">
{status === 'up'
? 'Up'
: status === 'down'
? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
<button
type="button"
className="system-test"
onClick={() => void testService(name)}
disabled={testing}
>
{testing ? 'Testing...' : 'Test'}
</button>
</div>
</div>
)
})}
</div>
</details>
<div className="recent-header">
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && (
<div className="recent-filter-group">
<label className="recent-filter">
<span>Show</span>
<select
value={recentDays}
onChange={(event) => setRecentDays(Number(event.target.value))}
>
<option value={0}>All</option>
<option value={30}>30 days</option>
<option value={60}>60 days</option>
<option value={90}>90 days</option>
<option value={180}>180 days</option>
</select>
</label>
<label className="recent-filter">
<span>Stage</span>
<select
value={recentStage}
onChange={(event) => setRecentStage(event.target.value)}
>
{REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</div>
)}
</div>
<div className="recent-grid">
{recentLoading ? (
<div className="loading-center">
<div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests</span>
</div>
) : recentError ? (
<button type="button" disabled>
{recentError}
</button>
) : recent.length === 0 ? (
<button type="button" disabled>
No recent requests found
</button>
) : (
recent.map((item) => (
<button
key={item.id}
type="button"
onClick={() => router.push(`/requests/${item.id}`)}
className="recent-card"
>
{item.artwork?.poster_url && (
<img
className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt=""
loading="lazy"
/>
)}
<span className="recent-info">
<span className="recent-title">
{item.title || 'Untitled'}
{item.year ? ` (${item.year})` : ''}
</span>
<span className="recent-meta">
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '}
{item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span>
</span>
</button>
))
)}
</div>
</section>
<aside className="side-panel">
<section className="main-panel find-panel">
<div className="find-header">
<h1>Search all requests</h1>
<p className="lede">
Search any request by title + year or request number and see whether it already
exists in the system.
</p>
</div>
<div className="find-controls">
<form onSubmit={submit} className="search search-row">
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="e.g. Dune 2021 or 1289"
/>
<button type="submit">Check status</button>
</form>
<div className="filters filters-compact">
<div className="filter">
<span>Type</span>
<div className="pill-group">
<button type="button">TV</button>
<button type="button">Movie</button>
</div>
</div>
<div className="filter">
<span>Status</span>
<div className="pill-group">
<button type="button">Pending</button>
<button type="button">Approved</button>
<button type="button">Processing</button>
<button type="button">Failed</button>
<button type="button">Available</button>
</div>
</div>
</div>
</div>
<section className="recent results-panel">
<h2>Search results</h2>
<div className="recent-grid">
{searchError ? (
<button type="button" disabled>
{searchError}
</button>
) : searchResults.length === 0 ? (
<button type="button" disabled>
No matches yet
</button>
) : (
searchResults.map((item, index) => (
<button
key={`${item.title || 'Untitled'}-${index}`}
type="button"
disabled={!item.requestId}
onClick={() =>
item.requestId && router.push(`/requests/${item.requestId}`)
}
>
{item.title || 'Untitled'} {item.year ? `(${item.year})` : ''}{' '}
{!item.requestId
? '- not requested'
: item.statusLabel
? `- ${item.statusLabel}`
: '- already requested'}
</button>
))
)}
</div>
</section>
</section>
</aside>
</div>
</main>
)
}
+1 -1
View File
@@ -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' },
],