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 ..auth import get_current_user_event_stream
from . import requests as requests_router from . import requests as requests_router
from .status import services_status
router = APIRouter(prefix="/events", tags=["events"]) router = APIRouter(prefix="/events", tags=["events"])
@@ -85,9 +84,7 @@ async def events_stream(
async def event_generator(): async def event_generator():
yield "retry: 2000\n\n" yield "retry: 2000\n\n"
last_recent_signature: Optional[str] = None last_recent_signature: Optional[str] = None
last_services_signature: Optional[str] = None
next_recent_at = 0.0 next_recent_at = 0.0
next_services_at = 0.0
heartbeat_counter = 0 heartbeat_counter = 0
while True: while True:
@@ -129,27 +126,6 @@ async def events_stream(
yield _sse_json(payload) yield _sse_json(payload)
sent_any = True 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: if sent_any:
heartbeat_counter = 0 heartbeat_counter = 0
else: else:
+2 -2
View File
@@ -2,7 +2,7 @@ from typing import Any, Dict
import httpx import httpx
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user from ..auth import require_admin
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..clients.jellyseerr import JellyseerrClient from ..clients.jellyseerr import JellyseerrClient
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
@@ -11,7 +11,7 @@ from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient from ..clients.qbittorrent import QBittorrentClient
from ..clients.jellyfin import JellyfinClient 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]: 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 starlette.requests import Request
from backend.app import db from backend.app import db
from backend.app.auth import require_admin
from backend.app.config import settings from backend.app.config import settings
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
from backend.app.models import NormalizedState, RequestType, Snapshot, TimelineHop from backend.app.models import NormalizedState, RequestType, Snapshot, TimelineHop
@@ -102,6 +103,11 @@ class NetworkSecurityTests(unittest.TestCase):
class ServiceStatusTests(unittest.IsolatedAsyncioTestCase): 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: async def test_qbittorrent_login_accepts_modern_empty_response_with_session_cookie(self) -> None:
class FakeClient: class FakeClient:
def __init__(self) -> None: 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.', 'Notification providers and delivery channel settings used by Magent messaging features.',
seerr: 'Connect Seerr where users submit content requests.', seerr: 'Connect Seerr where users submit content requests.',
jellyseerr: '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.', artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved requests cache and refresh behavior.', cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'TV automation settings.', sonarr: 'Sonarr connection and the default profile and library location for TV requests.',
radarr: 'Movie automation settings.', radarr: 'Radarr connection and the default profile and library location for movie requests.',
prowlarr: 'Indexer search settings.', prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
qbittorrent: 'Downloader connection settings.', qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
requests: 'Control how often requests are refreshed and cleaned up.', requests: 'Control how often requests are refreshed and cleaned up.',
log: 'Activity log for troubleshooting.', log: 'Activity log for troubleshooting.',
site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.', site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.',
@@ -639,6 +639,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const artworkSettingKeys = new Set(['artwork_cache_mode']) const artworkSettingKeys = new Set(['artwork_cache_mode'])
const generatedSettingKeys = new Set(['site_changelog']) const generatedSettingKeys = new Set(['site_changelog'])
const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys]) const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys, ...generatedSettingKeys])
const obsoleteSettingKeys = new Set([
'sonarr_qbittorrent_category',
'radarr_qbittorrent_category',
])
const requestSettingOrder = [ const requestSettingOrder = [
'requests_poll_interval_seconds', 'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes', 'requests_delta_sync_interval_minutes',
@@ -716,10 +720,13 @@ export default function SettingsPage({ section }: SettingsPageProps) {
title: SECTION_LABELS[sectionKey] ?? sectionKey, title: SECTION_LABELS[sectionKey] ?? sectionKey,
items: (() => { items: (() => {
const sectionItems = groupedSettings[sectionKey] ?? [] const sectionItems = groupedSettings[sectionKey] ?? []
const filtered = const filtered = sectionItems.filter((setting) => {
sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site' if (obsoleteSettingKeys.has(setting.key)) return false
? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key)) if (sectionKey === 'requests' || sectionKey === 'artwork' || sectionKey === 'site') {
: sectionItems return !hiddenSettingKeys.has(setting.key)
}
return true
})
if (sectionKey === 'requests') { if (sectionKey === 'requests') {
return sortByOrder(filtered, requestSettingOrder) return sortByOrder(filtered, requestSettingOrder)
} }
@@ -824,12 +831,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
sonarr_api_key: 'API key for Sonarr.', sonarr_api_key: 'API key for Sonarr.',
sonarr_quality_profile_id: 'Quality profile used when adding TV shows.', sonarr_quality_profile_id: 'Quality profile used when adding TV shows.',
sonarr_root_folder: 'Root folder where Sonarr stores 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_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.',
radarr_api_key: 'API key for Radarr.', radarr_api_key: 'API key for Radarr.',
radarr_quality_profile_id: 'Quality profile used when adding movies.', radarr_quality_profile_id: 'Quality profile used when adding movies.',
radarr_root_folder: 'Root folder where Radarr stores movies.', radarr_root_folder: 'Root folder where Radarr stores movies.',
radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.',
prowlarr_base_url: prowlarr_base_url:
'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.', 'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.',
prowlarr_api_key: 'API key for Prowlarr.', prowlarr_api_key: 'API key for Prowlarr.',
@@ -2398,7 +2403,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
onClick={() => void saveSettingGroup(sectionGroup)} onClick={() => void saveSettingGroup(sectionGroup)}
disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]} disabled={sectionSaving[sectionGroup.key] || sectionTesting[sectionGroup.key]}
> >
{sectionSaving[sectionGroup.key] ? 'Saving...' : 'Save section'} {sectionSaving[sectionGroup.key] ? 'Saving...' : `Save ${sectionGroup.title}`}
</button> </button>
</div> </div>
</section> </section>
+112 -25
View File
@@ -57,6 +57,9 @@ export default function AdminLandingPage() {
const [portalOverview, setPortalOverview] = useState<PortalOverview | null>(null) const [portalOverview, setPortalOverview] = useState<PortalOverview | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) 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(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
@@ -95,6 +98,7 @@ export default function AdminLandingPage() {
const data = await serviceResponse.json() const data = await serviceResponse.json()
setServiceOverall(data?.overall ?? 'unknown') setServiceOverall(data?.overall ?? 'unknown')
setServices(Array.isArray(data?.services) ? data.services : []) setServices(Array.isArray(data?.services) ? data.services : [])
setServiceCheckedAt(new Date().toISOString())
} }
if (recentResponse.ok) { if (recentResponse.ok) {
@@ -115,8 +119,58 @@ export default function AdminLandingPage() {
} }
void load() 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]) }, [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 serviceCounts = useMemo(() => {
const up = services.filter((service) => service.status === 'up').length const up = services.filter((service) => service.status === 'up').length
const down = services.filter((service) => service.status === 'down').length const down = services.filter((service) => service.status === 'down').length
@@ -132,27 +186,14 @@ export default function AdminLandingPage() {
const rail = ( const rail = (
<div className="admin-rail-stack"> <div className="admin-rail-stack">
<div className="admin-rail-card"> <div className="admin-rail-card">
<span className="admin-rail-eyebrow">Service ecosystem</span> <span className="admin-rail-eyebrow">Fleet summary</span>
<div className="service-ecosystem"> <h2>{serviceCounts.up} of {serviceCounts.total || 0} online</h2>
{services.length === 0 ? ( <p>
<div className="status-banner">Service status is not available yet.</div> {serviceCounts.down + serviceCounts.degraded > 0
) : ( ? `${serviceCounts.down + serviceCounts.degraded} configured service${serviceCounts.down + serviceCounts.degraded === 1 ? '' : 's'} need attention.`
services.map((service) => ( : 'No configured service is currently reporting a fault.'}
<a </p>
key={service.name} <a className="admin-rail-action" href="/admin/diagnostics">Open full diagnostics</a>
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>
</div> </div>
<div className="admin-rail-card"> <div className="admin-rail-card">
<span className="admin-rail-eyebrow">Quick actions</span> <span className="admin-rail-eyebrow">Quick actions</span>
@@ -168,12 +209,12 @@ export default function AdminLandingPage() {
return ( return (
<AdminShell <AdminShell
title="Operations Center" title="Admin overview"
subtitle="Live Magent controls, request movement, issue intake, and service health." subtitle="Service health, request movement, issue intake, and the controls that keep Magent running."
rail={rail} rail={rail}
actions={ actions={
<button type="button" onClick={() => router.push('/')}> <button type="button" onClick={() => router.push('/admin/diagnostics')}>
View health Run diagnostics
</button> </button>
} }
> >
@@ -205,6 +246,52 @@ export default function AdminLandingPage() {
</div> </div>
</section> </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"> <section className="admin-zone">
<div className="section-header"> <div className="section-header">
<div> <div>
+2 -2
View File
@@ -286,7 +286,7 @@ export default function AdminSystemGuidePage() {
<div className="system-guide-grid"> <div className="system-guide-grid">
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Landing page</h3> <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>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Request pages</h3> <h3>Request pages</h3>
@@ -294,7 +294,7 @@ export default function AdminSystemGuidePage() {
</article> </article>
<article className="system-guide-card"> <article className="system-guide-card">
<h3>Admin views</h3> <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> </article>
</div> </div>
</div> </div>
+425
View File
@@ -574,6 +574,215 @@ button:disabled,
color: var(--ops-muted); 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 { .layout-grid {
grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr); grid-template-columns: minmax(0, 1.35fr) minmax(340px, 0.65fr);
align-items: start; align-items: start;
@@ -921,6 +1130,110 @@ button:disabled,
gap: 16px; 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 { .admin-table {
overflow-x: auto; overflow-x: auto;
} }
@@ -1212,6 +1525,78 @@ button:disabled,
border-top: 1px solid var(--ops-line-soft); 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 { .service-status-panel {
display: grid; display: grid;
grid-template-columns: minmax(0, 0.82fr) minmax(520px, 1fr); grid-template-columns: minmax(0, 0.82fr) minmax(520px, 1fr);
@@ -1342,6 +1727,11 @@ button:disabled,
.side-panel { .side-panel {
position: static; position: static;
} }
.home-command {
grid-template-columns: 1fr;
align-items: stretch;
}
} }
@media (max-width: 780px) { @media (max-width: 780px) {
@@ -1439,6 +1829,9 @@ button:disabled,
} }
.ops-metric-grid, .ops-metric-grid,
.home-metric-strip,
.home-recent-grid,
.fleet-service-grid,
.portal-overview-grid, .portal-overview-grid,
.status-box, .status-box,
.history-grid, .history-grid,
@@ -1448,6 +1841,38 @@ button:disabled,
grid-template-columns: 1fr; 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, .search,
.portal-discovery-form, .portal-discovery-form,
.portal-toolbar, .portal-toolbar,
+88 -310
View File
@@ -64,13 +64,6 @@ export default function HomePage() {
const [recentDays, setRecentDays] = useState(90) const [recentDays, setRecentDays] = useState(90)
const [recentStage, setRecentStage] = useState('all') const [recentStage, setRecentStage] = useState('all')
const [authReady, setAuthReady] = useState(false) 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 [liveStreamConnected, setLiveStreamConnected] = useState(false)
const submit = (event: React.FormEvent) => { const submit = (event: React.FormEvent) => {
@@ -84,61 +77,6 @@ export default function HomePage() {
void runSearch(trimmed) 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(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
@@ -194,42 +132,6 @@ export default function HomePage() {
load() load()
}, [recentDays, recentStage]) }, [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(() => { useEffect(() => {
if (!authReady) { if (!authReady) {
setLiveStreamConnected(false) setLiveStreamConnected(false)
@@ -281,16 +183,6 @@ export default function HomePage() {
} }
return 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) { } catch (error) {
console.error(error) console.error(error)
} }
@@ -362,130 +254,96 @@ export default function HomePage() {
return date.toLocaleString() 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 activeRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase() const label = String(item.statusLabel ?? '').toLowerCase()
return !label.includes('ready') && !label.includes('available') && !label.includes('declined') return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
}).length }).length
const readyRecentCount = recent.filter((item) => {
const label = String(item.statusLabel ?? '').toLowerCase()
return label.includes('ready') || label.includes('available')
}).length
return ( return (
<main className="card"> <main className="card home-page">
<section className="ops-metric-grid"> <section className="home-command">
<div className="ops-metric-card"> <div className="home-command-copy">
<span className="section-kicker">Service mesh</span> <span className="section-kicker">Request lookup</span>
<strong> <h1>Find a media request</h1>
{serviceUpCount}/{serviceItems.length || 0} <p>
</strong> Enter a title and year, or jump straight to a request using its request number.
<p>{servicesLoading ? 'Checking services now.' : 'Configured services online.'}</p> </p>
</div> </div>
<div className="ops-metric-card"> <form onSubmit={submit} className="home-search">
<span className="section-kicker">Attention</span> <label htmlFor="request-search">Title, year, or request number</label>
<strong>{serviceAttentionCount}</strong> <div className="home-search-row">
<p>Services reporting down, degraded, or not configured.</p> <input
</div> id="request-search"
<div className="ops-metric-card"> value={query}
<span className="section-kicker">Loaded requests</span> onChange={(event) => setQuery(event.target.value)}
<strong>{recent.length}</strong> placeholder="Dune 2021 or 1289"
<p>Returned by the live request cache.</p> />
</div> <button type="submit">Find request</button>
<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> </div>
</form>
</section> </section>
<div className="layout-grid">
<section className="recent centerpiece"> {(searchError || searchResults.length > 0) && (
<details className="system-status system-status-dropdown"> <section className="home-search-results" aria-live="polite">
<summary className="system-summary"> <div className="home-section-heading">
<span className="system-summary-copy"> <div>
<span className="section-kicker">System status</span> <span className="section-kicker">Search results</span>
<strong>{serviceSummary}</strong> <h2>{searchError ? 'Search unavailable' : `${searchResults.length} match${searchResults.length === 1 ? '' : 'es'} found`}</h2>
<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>
<div className="system-actions"> <button type="button" className="ghost-button" onClick={() => {
<span className="system-state"> setSearchResults([])
{status === 'up' setSearchError(null)
? 'Up' }}>
: status === 'down' Clear
? '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> </button>
</div> </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> </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> </div>
</details>
<div className="recent-header">
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && ( {authReady && (
<div className="recent-filter-group"> <div className="recent-filter-group">
<label className="recent-filter"> <label className="recent-filter">
<span>Show</span> <span>Period</span>
<select <select value={recentDays} onChange={(event) => setRecentDays(Number(event.target.value))}>
value={recentDays} <option value={0}>All time</option>
onChange={(event) => setRecentDays(Number(event.target.value))}
>
<option value={0}>All</option>
<option value={30}>30 days</option> <option value={30}>30 days</option>
<option value={60}>60 days</option> <option value={60}>60 days</option>
<option value={90}>90 days</option> <option value={90}>90 days</option>
@@ -494,34 +352,28 @@ export default function HomePage() {
</label> </label>
<label className="recent-filter"> <label className="recent-filter">
<span>Stage</span> <span>Stage</span>
<select <select value={recentStage} onChange={(event) => setRecentStage(event.target.value)}>
value={recentStage}
onChange={(event) => setRecentStage(event.target.value)}
>
{REQUEST_STAGE_OPTIONS.map((option) => ( {REQUEST_STAGE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}> <option key={option.value} value={option.value}>{option.label}</option>
{option.label}
</option>
))} ))}
</select> </select>
</label> </label>
</div> </div>
)} )}
</div> </div>
<div className="recent-grid"> <div className="recent-grid home-recent-grid">
{recentLoading ? ( {recentLoading ? (
<div className="loading-center"> <div className="loading-center">
<div className="spinner" aria-hidden="true" /> <div className="spinner" aria-hidden="true" />
<span className="loading-text">Loading recent requests</span> <span className="loading-text">Loading recent requests...</span>
</div> </div>
) : recentError ? ( ) : recentError ? (
<button type="button" disabled> <div className="error-banner">{recentError}</div>
{recentError}
</button>
) : recent.length === 0 ? ( ) : recent.length === 0 ? (
<button type="button" disabled> <div className="home-empty-state">
No recent requests found <strong>No requests match these filters</strong>
</button> <span>Try a wider period or a different stage.</span>
</div>
) : ( ) : (
recent.map((item) => ( recent.map((item) => (
<button <button
@@ -530,103 +382,29 @@ export default function HomePage() {
onClick={() => router.push(`/requests/${item.id}`)} onClick={() => router.push(`/requests/${item.id}`)}
className="recent-card" className="recent-card"
> >
{item.artwork?.poster_url && ( {item.artwork?.poster_url ? (
<img <img
className="recent-poster" className="recent-poster"
src={resolveArtworkUrl(item.artwork.poster_url) ?? ''} src={resolveArtworkUrl(item.artwork.poster_url) ?? ''}
alt="" alt=""
loading="lazy" loading="lazy"
/> />
) : (
<span className="recent-poster recent-poster-placeholder" aria-hidden="true">#{item.id}</span>
)} )}
<span className="recent-info"> <span className="recent-info">
<span className="recent-title"> <span className="recent-title">{item.title || 'Untitled'}{item.year ? ` (${item.year})` : ''}</span>
{item.title || 'Untitled'}
{item.year ? ` (${item.year})` : ''}
</span>
<span className="recent-meta"> <span className="recent-meta">
{item.statusLabel ? item.statusLabel : 'Status not available yet'} · Request{' '} {item.statusLabel || 'Status not available yet'} · Request {item.id}
{item.id}
{item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''} {item.createdAt ? ` · ${formatRequestTime(item.createdAt)}` : ''}
</span> </span>
</span> </span>
<span className="recent-open-cue" aria-hidden="true">Open</span>
</button> </button>
)) ))
)} )}
</div> </div>
</section> </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> </main>
) )
} }
+1 -1
View File
@@ -7,7 +7,7 @@ const NAV_GROUPS = [
title: 'Operations', title: 'Operations',
items: [ items: [
{ href: '/admin', label: 'Overview' }, { href: '/admin', label: 'Overview' },
{ href: '/', label: 'Health' }, { href: '/admin/diagnostics', label: 'System health' },
{ href: '/portal/requests', label: 'Request portal' }, { href: '/portal/requests', label: 'Request portal' },
{ href: '/admin/issues', label: 'Issue tracking' }, { href: '/admin/issues', label: 'Issue tracking' },
], ],