Tidy beta landing page and qBittorrent status
Magent CI/CD / verify (push) Successful in 11m0s
Magent CI/CD / deploy-prod (push) Has been skipped
Magent CI/CD / deploy-beta (push) Successful in 15s

This commit is contained in:
2026-06-21 12:06:38 +12:00
parent e6b4f99ea7
commit e163920e21
5 changed files with 217 additions and 82 deletions
+3 -1
View File
@@ -23,7 +23,9 @@ class QBittorrentClient(ApiClient):
headers={"Referer": self.base_url}, headers={"Referer": self.base_url},
) )
response.raise_for_status() response.raise_for_status()
if response.text.strip().lower() != "ok.": text = response.text.strip().lower()
has_session_cookie = any(name.upper().startswith("QBT_SID") for name in client.cookies.keys())
if text not in {"ok.", ""} or (text == "" and not has_session_cookie):
raise RuntimeError("qBittorrent login failed") raise RuntimeError("qBittorrent login failed")
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]: async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
+14
View File
@@ -3,6 +3,7 @@ import tempfile
import unittest import unittest
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import httpx
from fastapi import HTTPException from fastapi import HTTPException
from starlette.requests import Request from starlette.requests import Request
@@ -97,6 +98,19 @@ class NetworkSecurityTests(unittest.TestCase):
class ServiceStatusTests(unittest.IsolatedAsyncioTestCase): class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
async def test_qbittorrent_login_accepts_modern_empty_response_with_session_cookie(self) -> None:
class FakeClient:
def __init__(self) -> None:
self.cookies = httpx.Cookies()
async def post(self, *_args, **_kwargs) -> httpx.Response:
self.cookies.set("QBT_SID_8080", "session")
return httpx.Response(204, request=httpx.Request("POST", "http://10.0.0.2:8080/api/v2/auth/login"))
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
await client._login(FakeClient())
async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None: async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None:
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None) client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None)
with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)): with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
+101
View File
@@ -598,6 +598,86 @@ button:disabled,
gap: 16px; gap: 16px;
} }
.system-status-dropdown {
display: block;
margin-bottom: 16px;
}
.system-status-dropdown summary {
list-style: none;
}
.system-status-dropdown summary::-webkit-details-marker {
display: none;
}
.system-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 14px 16px;
cursor: pointer;
border: 1px solid var(--ops-line-soft);
border-radius: var(--ops-radius);
background: rgba(255, 255, 255, 0.032);
}
.system-summary-copy {
display: grid;
gap: 4px;
min-width: 0;
}
.system-summary-copy strong {
color: var(--ops-text);
font-size: 1rem;
}
.system-summary-copy span:last-child {
color: var(--ops-muted);
font-size: 0.86rem;
}
.system-summary-actions {
display: inline-flex;
align-items: center;
gap: 10px;
flex: 0 0 auto;
}
.system-dropdown-cue {
min-width: 52px;
color: var(--ops-cyan);
font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.72rem;
font-weight: 700;
text-align: right;
text-transform: uppercase;
}
.system-status-dropdown[open] .system-dropdown-cue::before {
content: "Close";
font-size: 0.72rem;
}
.system-status-dropdown[open] .system-dropdown-cue {
font-size: 0;
}
.system-status-dropdown .system-list {
margin-top: 10px;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.system-status-dropdown .system-item {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.system-status-dropdown .system-actions {
flex-wrap: nowrap;
}
.system-header { .system-header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -658,6 +738,26 @@ button:disabled,
font-size: 0.78rem; font-size: 0.78rem;
} }
.find-panel .find-header {
display: grid;
gap: 6px;
}
.find-panel .find-header h1 {
margin: 0;
font-size: clamp(1.12rem, 1.5vw, 1.38rem);
line-height: 1.12;
}
.find-panel h2 {
font-size: 1.22rem;
}
.find-panel .lede {
font-size: 0.9rem;
line-height: 1.45;
}
.system-actions { .system-actions {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -679,6 +779,7 @@ button:disabled,
font-family: "JetBrains Mono", Consolas, monospace; font-family: "JetBrains Mono", Consolas, monospace;
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 700; font-weight: 700;
white-space: nowrap;
text-transform: uppercase; text-transform: uppercase;
} }
+71 -64
View File
@@ -367,6 +367,29 @@ export default function HomePage() {
const serviceAttentionCount = serviceItems.filter((service) => const serviceAttentionCount = serviceItems.filter((service) =>
['down', 'degraded', 'not_configured'].includes(service.status) ['down', 'degraded', 'not_configured'].includes(service.status)
).length ).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')
@@ -400,74 +423,58 @@ export default function HomePage() {
</section> </section>
<div className="layout-grid"> <div className="layout-grid">
<section className="recent centerpiece"> <section className="recent centerpiece">
<div className="system-status"> <details className="system-status system-status-dropdown">
<div className="system-header"> <summary className="system-summary">
<h2>System status</h2> <span className="system-summary-copy">
<span <span className="section-kicker">System status</span>
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`} <strong>{serviceSummary}</strong>
> <span>{serviceStatusLabel}</span>
{servicesLoading
? 'Checking services...'
: servicesError
? 'Status not available yet'
: servicesStatus?.overall === 'up'
? 'Services are up and running'
: servicesStatus?.overall === 'down'
? 'Something is down'
: 'Some services need attention'}
</span> </span>
</div> <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"> <div className="system-list">
{(() => { {orderedServices.map(({ name, status, message }) => {
const order = [ const testing = serviceTesting[name] ?? false
'Seerr', return (
'Sonarr', <div key={name} className={`system-item system-${status}`}>
'Radarr', <span className="system-dot" />
'Prowlarr', <div className="system-meta">
'qBittorrent', <span className="system-name">{name}</span>
'Jellyfin', <span className="system-test-message">
] {serviceTestResults[name] ?? message ?? 'No recent detail'}
const items = servicesStatus?.services ?? [] </span>
return order.map((name) => {
const item = items.find((entry) => entry.name === name)
const status = item?.status ?? 'unknown'
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>
{serviceTestResults[name] && (
<span className="system-test-message">{serviceTestResults[name]}</span>
)}
</div>
<div className="system-actions">
<span className="system-state">
{status === 'up'
? 'Up'
: status === 'down'
? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
<button
type="button"
className="system-test"
onClick={() => void testService(name)}
disabled={testing}
>
{testing ? 'Testing...' : 'Test'}
</button>
</div>
</div> </div>
) <div className="system-actions">
}) <span className="system-state">
})()} {status === 'up'
? 'Up'
: status === 'down'
? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
<button
type="button"
className="system-test"
onClick={() => void testService(name)}
disabled={testing}
>
{testing ? 'Testing...' : 'Test'}
</button>
</div>
</div>
)
})}
</div> </div>
</div> </details>
<div className="recent-header"> <div className="recent-header">
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2> <h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && ( {authReady && (
+28 -17
View File
@@ -38,41 +38,52 @@ export default function HeaderActions() {
return null return null
} }
const roleItems =
role === null
? []
: role === 'admin'
? [
{
href: '/admin',
label: 'Config',
match: (path: string) => path.startsWith('/admin'),
},
]
: [
{
href: '/profile',
label: 'Profile',
match: (path: string) => path.startsWith('/profile') && !path.startsWith('/profile/invites'),
},
{
href: '/profile/invites',
label: 'Invites',
match: (path: string) => path.startsWith('/profile/invites'),
},
]
const items = [ const items = [
{ href: '/', label: 'Health', icon: '01', match: (path: string) => path === '/' }, { href: '/', label: 'Health', match: (path: string) => path === '/' },
{ {
href: '/portal/requests', href: '/portal/requests',
label: 'Requests', label: 'Requests',
icon: '02',
match: (path: string) => path === '/portal/requests' || path.startsWith('/requests/'), match: (path: string) => path === '/portal/requests' || path.startsWith('/requests/'),
}, },
{ {
href: '/portal/issues', href: '/portal/issues',
label: 'Issues', label: 'Issues',
icon: '03',
match: (path: string) => path === '/portal/issues' || path === '/admin/issues', match: (path: string) => path === '/portal/issues' || path === '/admin/issues',
}, },
{ ...roleItems,
href: role === 'admin' ? '/users' : '/profile',
label: role === 'admin' ? 'Users' : 'Profile',
icon: '04',
match: (path: string) => path.startsWith('/users') || path.startsWith('/profile'),
},
{
href: role === 'admin' ? '/admin' : '/profile/invites',
label: role === 'admin' ? 'Config' : 'Invites',
icon: '05',
match: (path: string) => path.startsWith('/admin') || path.startsWith('/profile/invites'),
},
] ]
return ( return (
<nav className="header-actions" aria-label="Primary"> <nav className="header-actions" aria-label="Primary">
{items.map((item) => { {items.map((item, index) => {
const active = item.match(pathname) const active = item.match(pathname)
return ( return (
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}> <a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
<span aria-hidden="true">{item.icon}</span> <span aria-hidden="true">{String(index + 1).padStart(2, '0')}</span>
{item.label} {item.label}
</a> </a>
) )