Tidy beta landing page and qBittorrent status
This commit is contained in:
@@ -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]:
|
||||||
|
|||||||
@@ -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)):
|
||||||
|
|||||||
@@ -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
@@ -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 && (
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user