Tidy beta landing page and qBittorrent status
This commit is contained in:
@@ -23,7 +23,9 @@ class QBittorrentClient(ApiClient):
|
||||
headers={"Referer": self.base_url},
|
||||
)
|
||||
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")
|
||||
|
||||
async def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Optional[Any]:
|
||||
|
||||
@@ -3,6 +3,7 @@ import tempfile
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
from starlette.requests import Request
|
||||
|
||||
@@ -97,6 +98,19 @@ class NetworkSecurityTests(unittest.TestCase):
|
||||
|
||||
|
||||
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:
|
||||
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None)
|
||||
with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
|
||||
|
||||
@@ -598,6 +598,86 @@ button:disabled,
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -658,6 +738,26 @@ button:disabled,
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -679,6 +779,7 @@ button:disabled,
|
||||
font-family: "JetBrains Mono", Consolas, monospace;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
|
||||
+71
-64
@@ -367,6 +367,29 @@ export default function HomePage() {
|
||||
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')
|
||||
@@ -400,74 +423,58 @@ export default function HomePage() {
|
||||
</section>
|
||||
<div className="layout-grid">
|
||||
<section className="recent centerpiece">
|
||||
<div className="system-status">
|
||||
<div className="system-header">
|
||||
<h2>System status</h2>
|
||||
<span
|
||||
className={`system-pill system-pill-${servicesStatus?.overall ?? 'unknown'}`}
|
||||
>
|
||||
{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'}
|
||||
<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>
|
||||
</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">
|
||||
{(() => {
|
||||
const order = [
|
||||
'Seerr',
|
||||
'Sonarr',
|
||||
'Radarr',
|
||||
'Prowlarr',
|
||||
'qBittorrent',
|
||||
'Jellyfin',
|
||||
]
|
||||
const items = servicesStatus?.services ?? []
|
||||
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>
|
||||
{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 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>
|
||||
</details>
|
||||
<div className="recent-header">
|
||||
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
|
||||
{authReady && (
|
||||
|
||||
@@ -38,41 +38,52 @@ export default function HeaderActions() {
|
||||
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 = [
|
||||
{ href: '/', label: 'Health', icon: '01', match: (path: string) => path === '/' },
|
||||
{ href: '/', label: 'Health', match: (path: string) => path === '/' },
|
||||
{
|
||||
href: '/portal/requests',
|
||||
label: 'Requests',
|
||||
icon: '02',
|
||||
match: (path: string) => path === '/portal/requests' || path.startsWith('/requests/'),
|
||||
},
|
||||
{
|
||||
href: '/portal/issues',
|
||||
label: 'Issues',
|
||||
icon: '03',
|
||||
match: (path: string) => path === '/portal/issues' || path === '/admin/issues',
|
||||
},
|
||||
{
|
||||
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'),
|
||||
},
|
||||
...roleItems,
|
||||
]
|
||||
|
||||
return (
|
||||
<nav className="header-actions" aria-label="Primary">
|
||||
{items.map((item) => {
|
||||
{items.map((item, index) => {
|
||||
const active = item.match(pathname)
|
||||
return (
|
||||
<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}
|
||||
</a>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user