Move fleet health into admin settings and tidy landing page
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user