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 ..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,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]:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user