Move fleet health into admin settings and tidy landing page
This commit is contained in:
@@ -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,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]:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
+88
-310
@@ -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,130 +254,96 @@ 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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
{(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>
|
||||
<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 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>
|
||||
</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>
|
||||
<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>
|
||||
@@ -494,34 +352,28 @@ export default function HomePage() {
|
||||
</label>
|
||||
<label className="recent-filter">
|
||||
<span>Stage</span>
|
||||
<select
|
||||
value={recentStage}
|
||||
onChange={(event) => setRecentStage(event.target.value)}
|
||||
>
|
||||
<select value={recentStage} onChange={(event) => setRecentStage(event.target.value)}>
|
||||
{REQUEST_STAGE_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="recent-grid">
|
||||
<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>
|
||||
<span className="loading-text">Loading recent requests...</span>
|
||||
</div>
|
||||
) : recentError ? (
|
||||
<button type="button" disabled>
|
||||
{recentError}
|
||||
</button>
|
||||
<div className="error-banner">{recentError}</div>
|
||||
) : recent.length === 0 ? (
|
||||
<button type="button" disabled>
|
||||
No recent requests found
|
||||
</button>
|
||||
<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
|
||||
@@ -530,103 +382,29 @@ export default function HomePage() {
|
||||
onClick={() => router.push(`/requests/${item.id}`)}
|
||||
className="recent-card"
|
||||
>
|
||||
{item.artwork?.poster_url && (
|
||||
{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-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.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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user