admin docs and layout refresh, build 2702261314

This commit is contained in:
2026-02-27 13:17:50 +13:00
parent b84c27c698
commit 05a3d1e3b0
10 changed files with 1400 additions and 274 deletions

View File

@@ -1,4 +1,4 @@
BUILD_NUMBER = "2702261153" BUILD_NUMBER = "2702261314"
CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container' CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container'

View File

@@ -1262,6 +1262,86 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
} }
const cacheSourceLabel =
formValues.requests_data_source === 'always_js'
? 'Jellyseerr direct'
: formValues.requests_data_source === 'prefer_cache'
? 'Saved requests only'
: 'Saved requests only'
const cacheTtlLabel = formValues.requests_sync_ttl_minutes || '60'
const cacheRail = showCacheExtras ? (
<div className="admin-rail-stack">
<div className="admin-rail-card cache-rail-card">
<span className="admin-rail-eyebrow">Cache control</span>
<h2>Saved requests</h2>
<p>Load and inspect cached request entries from the right rail.</p>
<div className="cache-rail-metrics">
<div className="cache-rail-metric">
<span>Data source</span>
<strong>{cacheSourceLabel}</strong>
</div>
<div className="cache-rail-metric">
<span>Refresh TTL</span>
<strong>{cacheTtlLabel} min</strong>
</div>
<div className="cache-rail-metric">
<span>Rows loaded</span>
<strong>{cacheRows.length}</strong>
</div>
<div className="cache-rail-metric">
<span>Live updates</span>
<strong>{liveStreamConnected ? 'Connected' : 'Polling'}</strong>
</div>
</div>
<label className="cache-rail-limit">
<span>Rows to load</span>
<select
value={cacheCount}
onChange={(event) => setCacheCount(Number(event.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
<button type="button" onClick={loadCache} disabled={cacheLoading}>
{cacheLoading ? (
<>
<span className="spinner button-spinner" aria-hidden="true" />
Loading saved requests
</>
) : (
'Load saved requests'
)}
</button>
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
</div>
<div className="admin-rail-card cache-rail-card">
<span className="admin-rail-eyebrow">Artwork</span>
<h2>Cache stats</h2>
<div className="cache-rail-metrics">
<div className="cache-rail-metric">
<span>Missing artwork</span>
<strong>{artworkSummary?.missing_artwork ?? '--'}</strong>
</div>
<div className="cache-rail-metric">
<span>Cache size</span>
<strong>{formatBytes(artworkSummary?.cache_bytes)}</strong>
</div>
<div className="cache-rail-metric">
<span>Cached files</span>
<strong>{artworkSummary?.cache_files ?? '--'}</strong>
</div>
<div className="cache-rail-metric">
<span>Mode</span>
<strong>{artworkSummary?.cache_mode ?? '--'}</strong>
</div>
</div>
</div>
</div>
) : undefined
if (loading) { if (loading) {
return <main className="card">Loading admin settings...</main> return <main className="card">Loading admin settings...</main>
} }
@@ -1270,6 +1350,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<AdminShell <AdminShell
title={SECTION_LABELS[section] ?? 'Settings'} title={SECTION_LABELS[section] ?? 'Settings'}
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'} subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
rail={cacheRail}
actions={ actions={
<button type="button" onClick={() => router.push('/admin')}> <button type="button" onClick={() => router.push('/admin')}>
Back to settings Back to settings
@@ -1893,32 +1974,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<section className="admin-section" id="cache"> <section className="admin-section" id="cache">
<div className="section-header"> <div className="section-header">
<h2>Saved requests (cache)</h2> <h2>Saved requests (cache)</h2>
<div className="log-actions">
<label className="recent-filter">
<span>Rows to show</span>
<select
value={cacheCount}
onChange={(event) => setCacheCount(Number(event.target.value))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</label>
<button type="button" onClick={loadCache} disabled={cacheLoading}>
{cacheLoading ? (
<>
<span className="spinner button-spinner" aria-hidden="true" />
Loading saved requests
</>
) : (
'Load saved requests'
)}
</button>
</div> </div>
</div>
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
<div className="cache-table"> <div className="cache-table">
<div className="cache-row cache-head"> <div className="cache-row cache-head">
<span>Request</span> <span>Request</span>

View File

@@ -70,6 +70,21 @@ type ProfileForm = {
} }
type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace' type InviteManagementTab = 'bulk' | 'profiles' | 'invites' | 'trace'
type InviteTraceScope = 'all' | 'invited' | 'direct'
type InviteTraceView = 'list' | 'graph'
type InviteTraceRow = {
username: string
role: string
authProvider: string
level: number
inviterUsername: string | null
inviteCode: string | null
inviteLabel: string | null
createdAt: string | null
childCount: number
isCycle?: boolean
}
type InvitePolicy = { type InvitePolicy = {
master_invite_id?: number | null master_invite_id?: number | null
@@ -105,6 +120,9 @@ const formatDate = (value?: string | null) => {
return date.toLocaleString() return date.toLocaleString()
} }
const isInviteTraceRowInvited = (row: InviteTraceRow) =>
Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim())
export default function AdminInviteManagementPage() { export default function AdminInviteManagementPage() {
const router = useRouter() const router = useRouter()
const [invites, setInvites] = useState<Invite[]>([]) const [invites, setInvites] = useState<Invite[]>([])
@@ -135,6 +153,8 @@ export default function AdminInviteManagementPage() {
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null) const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk') const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [traceFilter, setTraceFilter] = useState('') const [traceFilter, setTraceFilter] = useState('')
const [traceScope, setTraceScope] = useState<InviteTraceScope>('all')
const [traceView, setTraceView] = useState<InviteTraceView>('graph')
const signupBaseUrl = useMemo(() => { const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup' if (typeof window === 'undefined') return '/signup'
@@ -698,22 +718,50 @@ export default function AdminInviteManagementPage() {
) )
}, [invites, traceFilter, users]) }, [invites, traceFilter, users])
return ( const scopedInviteTraceRows = useMemo(() => {
<AdminShell if (traceScope === 'invited') return inviteTraceRows.filter((row) => isInviteTraceRowInvited(row))
title="Invite management" if (traceScope === 'direct') return inviteTraceRows.filter((row) => !isInviteTraceRowInvited(row))
subtitle="Manage invite links, reusable profiles, and blanket invite-related defaults." return inviteTraceRows
> }, [inviteTraceRows, traceScope])
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="admin-panel invite-admin-summary-panel"> const traceInvitedCount = useMemo(
() => inviteTraceRows.filter((row) => isInviteTraceRowInvited(row)).length,
[inviteTraceRows]
)
const traceDirectCount = inviteTraceRows.length - traceInvitedCount
const inviteTraceGraphColumns = useMemo(() => {
if (scopedInviteTraceRows.length === 0) return [] as Array<{ level: number; rows: InviteTraceRow[] }>
const minLevel = Math.min(...scopedInviteTraceRows.map((row) => row.level))
const grouped = new Map<number, InviteTraceRow[]>()
scopedInviteTraceRows.forEach((row) => {
const level = Math.max(0, row.level - minLevel)
const bucket = grouped.get(level) ?? []
bucket.push(row)
grouped.set(level, bucket)
})
return Array.from(grouped.entries())
.sort((a, b) => a[0] - b[0])
.map(([level, rows]) => ({
level,
rows: [...rows].sort((a, b) =>
String(a.username || '').localeCompare(String(b.username || ''), undefined, {
sensitivity: 'base',
})
),
}))
}, [scopedInviteTraceRows])
const inviteManagementRail = (
<div className="admin-rail-stack">
<div className="admin-rail-card invite-admin-summary-panel">
<div className="invite-admin-summary-header"> <div className="invite-admin-summary-header">
<div> <div>
<h2>Overview</h2> <span className="admin-rail-eyebrow">Overview</span>
<p className="lede"> <h2>Invite stats</h2>
Quick counts for invite links, profiles, and managed user defaults. <p className="lede">Live counts for invites, profiles, and managed user defaults.</p>
</p>
</div> </div>
</div> </div>
<div className="invite-admin-summary-list"> <div className="invite-admin-summary-list">
@@ -742,7 +790,9 @@ export default function AdminInviteManagementPage() {
<span className="label">Jellyfin users</span> <span className="label">Jellyfin users</span>
<div className="invite-admin-summary-row__value"> <div className="invite-admin-summary-row__value">
<strong>{jellyfinUsersCount ?? '—'}</strong> <strong>{jellyfinUsersCount ?? '—'}</strong>
<span>{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}</span> <span>
{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}
</span>
</div> </div>
</div> </div>
<div className="invite-admin-summary-row"> <div className="invite-admin-summary-row">
@@ -765,6 +815,18 @@ export default function AdminInviteManagementPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
)
return (
<AdminShell
title="Invite management"
subtitle="Manage invite links, reusable profiles, and blanket invite-related defaults."
rail={inviteManagementRail}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="invite-admin-tabbar"> <div className="invite-admin-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Invite management sections"> <div className="admin-segmented" role="tablist" aria-label="Invite management sections">
@@ -833,8 +895,8 @@ export default function AdminInviteManagementPage() {
</div> </div>
{activeTab === 'bulk' && ( {activeTab === 'bulk' && (
<div className="admin-split-grid invite-admin-bulk-grid"> <div className="invite-admin-stack">
<div className="admin-panel"> <div className="admin-panel invite-admin-bulk-panel">
<div className="user-directory-panel-header"> <div className="user-directory-panel-header">
<div> <div>
<h2>Blanket controls</h2> <h2>Blanket controls</h2>
@@ -843,13 +905,6 @@ export default function AdminInviteManagementPage() {
</p> </p>
</div> </div>
</div> </div>
<div className="admin-meta-row">
<span>Local non-admin users: {nonAdminUsers.length}</span>
<span>Jellyfin users: {jellyfinUsersCount ?? '—'}</span>
<span>Invite access enabled: {inviteAccessEnabledUsers}</span>
<span>Profile assigned: {profiledUsers}</span>
<span>Custom expiry set: {expiringUsers}</span>
</div>
<div className="user-bulk-groups"> <div className="user-bulk-groups">
<div className="user-bulk-group"> <div className="user-bulk-group">
<div className="user-bulk-group-meta"> <div className="user-bulk-group-meta">
@@ -961,46 +1016,6 @@ export default function AdminInviteManagementPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="admin-panel">
<div className="user-directory-panel-header">
<div>
<h2>How this page is organized</h2>
<p className="lede">Use tabs to switch between blanket controls, reusable profiles, and invite links.</p>
</div>
</div>
<div className="admin-list">
<div className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>Profiles</strong>
</div>
<p className="admin-list-item-text">
Create reusable account defaults and apply them to invite links or existing users.
</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => setActiveTab('profiles')}>
Open
</button>
</div>
</div>
<div className="admin-list-item">
<div className="admin-list-item-main">
<div className="admin-list-item-title-row">
<strong>Invites</strong>
</div>
<p className="admin-list-item-text">
Create and manage signup links, assign profiles, and copy shareable URLs.
</p>
</div>
<div className="admin-inline-actions">
<button type="button" className="ghost-button" onClick={() => setActiveTab('invites')}>
Open
</button>
</div>
</div>
</div>
</div>
</div> </div>
)} )}
@@ -1410,19 +1425,105 @@ export default function AdminInviteManagementPage() {
placeholder="Search by username, inviter, or invite code" placeholder="Search by username, inviter, or invite code"
/> />
</label> </label>
<div className="invite-trace-controls">
<label className="invite-trace-scope">
<span>Scope</span>
<select
value={traceScope}
onChange={(e) =>
setTraceScope(e.target.value as InviteTraceScope)
}
>
<option value="all">All users</option>
<option value="invited">Invited only</option>
<option value="direct">Direct / root only</option>
</select>
</label>
<div className="invite-trace-view-toggle" role="group" aria-label="Trace view mode">
<button
type="button"
className={traceView === 'graph' ? 'is-active' : ''}
onClick={() => setTraceView('graph')}
>
Graph
</button>
<button
type="button"
className={traceView === 'list' ? 'is-active' : ''}
onClick={() => setTraceView('list')}
>
List
</button>
</div>
</div>
<div className="invite-trace-summary"> <div className="invite-trace-summary">
<span>{inviteTraceRows.length} rows shown</span> <span>{scopedInviteTraceRows.length} rows shown</span>
<span>{traceInvitedCount} invited</span>
<span>{traceDirectCount} direct/root</span>
<span>{users.length} users loaded</span> <span>{users.length} users loaded</span>
<span>{invites.length} invites loaded</span> <span>{invites.length} invites loaded</span>
</div> </div>
</div> </div>
{loading ? ( {loading ? (
<div className="status-banner">Loading trace map</div> <div className="status-banner">Loading trace map</div>
) : inviteTraceRows.length === 0 ? ( ) : scopedInviteTraceRows.length === 0 ? (
<div className="status-banner">No trace matches found.</div> <div className="status-banner">No trace matches found.</div>
) : traceView === 'graph' ? (
<div className="invite-trace-graph">
{inviteTraceGraphColumns.map((column) => (
<section key={`trace-level-${column.level}`} className="invite-trace-column">
<header className="invite-trace-column-header">
<span>Level {column.level}</span>
<strong>{column.rows.length}</strong>
</header>
<div className="invite-trace-column-body">
{column.rows.map((row) => (
<article
key={`${row.username}-${column.level}-${row.inviteCode || 'direct'}`}
className="invite-trace-node"
>
<div className="invite-trace-node-main">
<div className="invite-trace-node-title">
<span className="invite-trace-user">{row.username}</span>
<span className={`small-pill ${row.role === 'admin' ? '' : 'is-muted'}`}>{row.role}</span>
<span className="small-pill">{row.authProvider}</span>
{row.isCycle && <span className="small-pill is-muted">cycle</span>}
</div>
<p className={`invite-trace-node-arrow ${isInviteTraceRowInvited(row) ? '' : 'is-root'}`}>
{row.inviterUsername
? `\u2190 Invited by ${row.inviterUsername}`
: row.inviteCode
? `\u2190 Invited via code ${row.inviteCode}`
: 'Direct/root account'}
</p>
</div>
<div className="invite-trace-node-meta">
<span className="invite-trace-node-meta-item">
<span className="label">Invite code</span>
<strong>{row.inviteCode || 'None'}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Invite label</span>
<strong>{row.inviteLabel || 'None'}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Children</span>
<strong>{row.childCount}</strong>
</span>
<span className="invite-trace-node-meta-item">
<span className="label">Created</span>
<strong>{formatDate(row.createdAt)}</strong>
</span>
</div>
</article>
))}
</div>
</section>
))}
</div>
) : ( ) : (
<div className="invite-trace-map"> <div className="invite-trace-map">
{inviteTraceRows.map((row) => ( {scopedInviteTraceRows.map((row) => (
<div key={`${row.username}-${row.level}-${row.inviteCode || 'direct'}`} className="invite-trace-row"> <div key={`${row.username}-${row.level}-${row.inviteCode || 'direct'}`} className="invite-trace-row">
<div className="invite-trace-row-main" style={{ paddingLeft: `${row.level * 18}px` }}> <div className="invite-trace-row-main" style={{ paddingLeft: `${row.level * 18}px` }}>
<span className="invite-trace-branch" aria-hidden="true" /> <span className="invite-trace-branch" aria-hidden="true" />

View File

@@ -0,0 +1,211 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import AdminShell from '../../ui/AdminShell'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
type FlowStage = {
title: string
input: string
action: string
output: string
}
const REQUEST_FLOW: FlowStage[] = [
{
title: 'Identity + access',
input: 'Jellyfin/local login',
action: 'Magent validates credentials and role',
output: 'JWT token + user scope',
},
{
title: 'Request intake',
input: 'Jellyseerr request ID',
action: 'Magent snapshots request + media metadata',
output: 'Unified request state',
},
{
title: 'Queue orchestration',
input: 'Approved request',
action: 'Sonarr/Radarr add/search operations',
output: 'Grab decision',
},
{
title: 'Download execution',
input: 'Selected release',
action: 'qBittorrent downloads + reports progress',
output: 'Import-ready payload',
},
{
title: 'Library import',
input: 'Completed download',
action: 'Sonarr/Radarr import and finalize',
output: 'Available media object',
},
{
title: 'Playback availability',
input: 'Imported media',
action: 'Jellyfin refresh + link resolution',
output: 'Ready-to-watch state',
},
]
export default function AdminSystemGuidePage() {
const router = useRouter()
const [loading, setLoading] = useState(true)
const [authorized, setAuthorized] = useState(false)
useEffect(() => {
let active = true
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
router.push('/')
return
}
const me = await response.json()
if (!active) return
if (me?.role !== 'admin') {
router.push('/')
return
}
setAuthorized(true)
} catch (error) {
console.error(error)
router.push('/')
} finally {
if (active) setLoading(false)
}
}
void load()
return () => {
active = false
}
}, [router])
if (loading) {
return <main className="card">Loading system guide...</main>
}
if (!authorized) {
return null
}
const rail = (
<div className="admin-rail-stack">
<div className="admin-rail-card">
<span className="admin-rail-eyebrow">Guide map</span>
<h2>Quick path</h2>
<p>Identity Intake Queue Download Import Playback.</p>
<span className="small-pill">Admin only</span>
</div>
</div>
)
return (
<AdminShell
title="System guide"
subtitle="Admin-only architecture and operational flow for Magent."
rail={rail}
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section system-guide">
<div className="admin-panel">
<h2>End-to-end system flow</h2>
<p className="lede">
This is the exact runtime path for request processing and availability in the current build.
</p>
<div className="system-flow-track">
{REQUEST_FLOW.map((stage, index) => (
<div key={stage.title} className="system-flow-segment">
<article className="system-flow-card">
<div className="system-flow-card-title">{index + 1}. {stage.title}</div>
<div className="system-flow-card-row">
<span>Input</span>
<strong>{stage.input}</strong>
</div>
<div className="system-flow-card-row">
<span>Action</span>
<strong>{stage.action}</strong>
</div>
<div className="system-flow-card-row">
<span>Output</span>
<strong>{stage.output}</strong>
</div>
</article>
{index < REQUEST_FLOW.length - 1 && <div className="system-flow-arrow" aria-hidden="true"></div>}
</div>
))}
</div>
</div>
<div className="admin-panel">
<h2>Operational controls by area</h2>
<div className="system-guide-grid">
<article className="system-guide-card">
<h3>General</h3>
<p>Application URL, API URL, ports, bind host, proxy base URL, and manual SSL settings.</p>
</article>
<article className="system-guide-card">
<h3>Notifications</h3>
<p>Email, Discord, Telegram, push/mobile, and generic webhook delivery channels.</p>
</article>
<article className="system-guide-card">
<h3>Users</h3>
<p>Role/profile/expiry, auto-search access, invite access, and cross-system ban/remove actions.</p>
</article>
<article className="system-guide-card">
<h3>Invite management</h3>
<p>Master template, profile assignment, invite access policy, and invite trace map lineage.</p>
</article>
<article className="system-guide-card">
<h3>Requests + cache</h3>
<p>All-requests view, sync controls, cached request records, and maintenance operations.</p>
</article>
<article className="system-guide-card">
<h3>Live request page</h3>
<p>Event-stream updates for state, action history, and torrent progress without page refresh.</p>
</article>
</div>
</div>
<div className="admin-panel">
<h2>Stall recovery path (decision flow)</h2>
<ol className="system-decision-list">
<li>
Request approved but not in Arr queue <span></span> run <strong>Re-add to Arr</strong>.
</li>
<li>
In queue but no release found <span></span> run <strong>Search releases</strong> and inspect options.
</li>
<li>
Release exists and user should not pick manually <span></span> run <strong>Search + auto-download</strong>.
</li>
<li>
Download paused/stalled in qBittorrent <span></span> run <strong>Resume download</strong>.
</li>
<li>
Imported but not visible to user <span></span> validate Jellyfin visibility/link from request page.
</li>
</ol>
</div>
</section>
</AdminShell>
)
}

View File

@@ -4193,7 +4193,7 @@ button:hover:not(:disabled) {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
gap: 10px; gap: 10px;
align-items: end; align-items: flex-end;
} }
.user-directory-search { .user-directory-search {
@@ -4531,19 +4531,22 @@ button:hover:not(:disabled) {
.invite-admin-tabbar { .invite-admin-tabbar {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: minmax(0, 1fr) auto;
align-items: start; align-items: center;
gap: 8px; gap: 10px 12px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.invite-admin-tabbar .admin-segmented { .invite-admin-tabbar .admin-segmented {
margin-bottom: 0; margin-bottom: 0;
width: max-content;
max-width: 100%;
} }
.invite-admin-tab-actions { .invite-admin-tab-actions {
width: 100%; width: auto;
justify-content: flex-end; justify-content: flex-end;
align-self: center;
} }
.invite-admin-stack { .invite-admin-stack {
@@ -4551,6 +4554,11 @@ button:hover:not(:disabled) {
gap: 12px; gap: 12px;
} }
.invite-admin-bulk-panel .user-bulk-groups {
display: grid;
gap: 10px;
}
.invite-admin-list-panel, .invite-admin-list-panel,
.invite-admin-form-panel { .invite-admin-form-panel {
width: 100%; width: 100%;
@@ -4709,6 +4717,7 @@ button:hover:not(:disabled) {
} }
.invite-admin-tabbar { .invite-admin-tabbar {
grid-template-columns: 1fr;
align-items: stretch; align-items: stretch;
} }
@@ -4761,12 +4770,16 @@ textarea {
.invite-trace-toolbar { .invite-trace-toolbar {
display: grid; display: grid;
grid-template-columns: minmax(260px, 1fr) auto; grid-template-columns: minmax(260px, 1fr) auto;
grid-template-areas:
'filter controls'
'summary summary';
gap: 10px 14px; gap: 10px 14px;
align-items: end; align-items: flex-end;
margin-bottom: 10px; margin-bottom: 10px;
} }
.invite-trace-filter { .invite-trace-filter {
grid-area: filter;
display: grid; display: grid;
gap: 6px; gap: 6px;
} }
@@ -4779,6 +4792,7 @@ textarea {
} }
.invite-trace-summary { .invite-trace-summary {
grid-area: summary;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-end; justify-content: flex-end;
@@ -4794,11 +4808,164 @@ textarea {
border-radius: 5px; border-radius: 5px;
} }
.invite-trace-controls {
grid-area: controls;
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: flex-end;
gap: 10px;
}
.invite-trace-scope {
display: grid;
gap: 6px;
min-width: 190px;
}
.invite-trace-scope > span {
color: #9ea7b6;
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.invite-trace-view-toggle {
display: inline-flex;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
border-radius: 5px;
overflow: hidden;
}
.invite-trace-view-toggle button {
min-width: 90px;
border: 0;
border-right: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 0;
background: transparent;
color: #aeb7c4;
padding: 7px 12px;
}
.invite-trace-view-toggle button:last-child {
border-right: 0;
}
.invite-trace-view-toggle button.is-active {
background: rgba(111, 148, 224, 0.22);
color: #eef3fb;
font-weight: 700;
}
.invite-trace-map { .invite-trace-map {
display: grid; display: grid;
gap: 8px; gap: 8px;
} }
.invite-trace-graph {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(260px, 1fr);
align-items: start;
gap: 10px;
overflow-x: auto;
padding-bottom: 2px;
}
.invite-trace-column {
display: grid;
gap: 8px;
align-content: start;
min-width: 260px;
}
.invite-trace-column-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.015);
border-radius: 5px;
color: #b5c0d2;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.invite-trace-column-header strong {
color: #edf2f8;
font-size: 0.86rem;
}
.invite-trace-column-body {
display: grid;
gap: 8px;
}
.invite-trace-node {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.018);
border-radius: 6px;
}
.invite-trace-node-main {
display: grid;
gap: 6px;
}
.invite-trace-node-title {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
}
.invite-trace-node-arrow {
margin: 0;
color: #c4cfdd;
font-size: 0.82rem;
letter-spacing: 0.01em;
}
.invite-trace-node-arrow.is-root {
color: #95a2b5;
}
.invite-trace-node-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
}
.invite-trace-node-meta-item {
display: grid;
gap: 2px;
align-content: start;
min-height: 44px;
padding: 6px 8px;
border: 1px solid rgba(255, 255, 255, 0.04);
background: rgba(255, 255, 255, 0.01);
border-radius: 5px;
}
.invite-trace-node-meta-item .label {
color: #8f9aac;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.invite-trace-node-meta-item strong {
color: #e7edf6;
font-size: 0.81rem;
word-break: break-word;
}
.invite-trace-row { .invite-trace-row {
display: grid; display: grid;
grid-template-columns: minmax(260px, 420px) minmax(0, 1fr); grid-template-columns: minmax(260px, 420px) minmax(0, 1fr);
@@ -4876,13 +5043,30 @@ textarea {
@media (max-width: 1180px) { @media (max-width: 1180px) {
.invite-trace-toolbar { .invite-trace-toolbar {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-areas:
'filter'
'controls'
'summary';
align-items: stretch; align-items: stretch;
} }
.invite-trace-controls {
justify-content: flex-start;
}
.invite-trace-summary { .invite-trace-summary {
justify-content: flex-start; justify-content: flex-start;
} }
.invite-trace-graph {
grid-auto-flow: row;
grid-auto-columns: 1fr;
}
.invite-trace-column {
min-width: 0;
}
.invite-trace-row { .invite-trace-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -4893,6 +5077,10 @@ textarea {
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.invite-trace-node-meta {
grid-template-columns: 1fr;
}
.invite-trace-row-meta { .invite-trace-row-meta {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -4969,6 +5157,119 @@ textarea {
color: #e7edf6; color: #e7edf6;
} }
/* Admin system guide */
.system-guide {
display: grid;
gap: 12px;
}
.system-flow-track {
display: grid;
gap: 10px;
margin-top: 8px;
}
.system-flow-segment {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
}
.system-flow-card {
display: grid;
gap: 7px;
padding: 11px 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
border-radius: 6px;
}
.system-flow-card-title {
color: #edf2f8;
font-weight: 700;
letter-spacing: 0.01em;
}
.system-flow-card-row {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 8px;
align-items: start;
}
.system-flow-card-row span {
color: #8f9aac;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.system-flow-card-row strong {
color: #dfe8f5;
font-size: 0.86rem;
font-weight: 600;
}
.system-flow-arrow {
color: #7ea1d8;
font-size: 1.2rem;
font-weight: 700;
line-height: 1;
}
.system-guide-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
margin-top: 8px;
}
.system-guide-card {
padding: 11px 12px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
border-radius: 6px;
display: grid;
gap: 6px;
}
.system-guide-card h3 {
color: #eef3fb;
font-size: 0.97rem;
}
.system-guide-card p {
color: #a7b2c2;
margin: 0;
}
.system-decision-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: grid;
gap: 7px;
}
.system-decision-list li {
padding: 9px 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.015);
border-radius: 6px;
color: #d3dce9;
}
.system-decision-list li span {
color: #7ea1d8;
font-weight: 700;
margin: 0 5px;
}
.system-decision-list li strong {
color: #eff5ff;
}
@media (max-width: 980px) { @media (max-width: 980px) {
.profile-invites-layout { .profile-invites-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -4979,6 +5280,287 @@ textarea {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
/* Admin shell right rail */
.admin-shell {
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr) minmax(300px, 380px);
gap: 22px;
align-items: start;
}
.admin-shell-nav {
grid-column: 1;
}
.admin-card {
grid-column: 2;
min-width: 0;
}
.admin-shell-rail {
grid-column: 3;
position: sticky;
top: 20px;
align-self: start;
display: grid;
gap: 10px;
min-width: 0;
}
.admin-rail-stack {
display: grid;
gap: 10px;
}
.admin-rail-card {
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.016);
border-radius: 8px;
padding: 12px;
display: grid;
gap: 8px;
min-width: 0;
}
.admin-rail-card h2 {
margin: 0;
font-size: 1rem;
}
.admin-rail-card p {
margin: 0;
color: #9ba5b5;
}
.admin-rail-eyebrow {
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #9ba5b5;
font-weight: 700;
}
.admin-shell-rail .invite-admin-summary-row {
grid-template-columns: 1fr;
align-items: start;
}
.admin-shell-rail .invite-admin-summary-row__value {
justify-content: space-between;
}
.cache-rail-card {
gap: 10px;
}
.cache-rail-metrics {
display: grid;
gap: 8px;
}
.cache-rail-metric {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.012);
padding: 8px 10px;
border-radius: 6px;
}
.cache-rail-metric span {
color: #9aa4b4;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.cache-rail-metric strong {
color: #eef3f9;
font-size: 0.92rem;
text-align: right;
overflow-wrap: anywhere;
}
.cache-rail-limit {
display: grid;
gap: 6px;
}
.cache-rail-limit > span {
color: #9aa4b4;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Users page streamline pass */
.users-page-toolbar {
margin-bottom: 12px;
}
.users-page-toolbar-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.users-page-toolbar-group {
display: grid;
gap: 8px;
min-width: 0;
padding: 10px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.016);
border-radius: 6px;
}
.users-page-toolbar-label {
font-size: 0.72rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #9ba5b5;
font-weight: 700;
}
.users-page-toolbar-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.users-page-toolbar-actions button {
white-space: nowrap;
}
.users-page-overview-grid {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr);
gap: 12px;
margin: 12px 0;
align-items: start;
}
.users-summary-panel {
display: grid;
gap: 10px;
}
.users-rail-summary .users-summary-grid {
grid-template-columns: 1fr;
}
.users-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.users-summary-card {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
background: rgba(255, 255, 255, 0.014);
border-radius: 6px;
}
.users-summary-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.users-summary-label {
color: #a9b3c2;
font-size: 0.85rem;
font-weight: 600;
}
.users-summary-value {
color: #edf3fb;
font-size: 1.12rem;
line-height: 1;
font-weight: 700;
}
.users-summary-meta {
margin: 0;
color: #98a3b4;
font-size: 0.78rem;
line-height: 1.35;
}
.user-directory-search-panel {
margin-bottom: 12px;
}
.user-directory-bulk-panel .user-bulk-toolbar {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: 10px;
}
.user-directory-bulk-panel .user-bulk-summary {
display: grid;
gap: 4px;
align-content: start;
min-width: 0;
}
.user-directory-bulk-panel .user-bulk-summary strong {
line-height: 1.32;
overflow-wrap: anywhere;
}
.user-directory-bulk-panel .user-bulk-actions {
align-self: start;
justify-content: flex-start;
}
.user-directory-bulk-panel .user-bulk-actions button {
min-width: 190px;
}
@media (max-width: 1400px) {
.admin-shell {
grid-template-columns: minmax(210px, 250px) minmax(0, 1fr) minmax(270px, 320px);
}
}
@media (max-width: 980px) {
.admin-shell {
grid-template-columns: 1fr;
}
.admin-shell-nav,
.admin-card,
.admin-shell-rail {
grid-column: 1;
}
.users-page-toolbar-grid,
.users-summary-grid {
grid-template-columns: 1fr;
}
.users-page-toolbar-actions button {
flex: 1 1 220px;
}
.user-directory-bulk-panel .user-bulk-actions {
width: 100%;
}
.user-directory-bulk-panel .user-bulk-actions button {
width: 100%;
min-width: 0;
}
}
/* Final header account menu stacking override (must be last) */ /* Final header account menu stacking override (must be last) */
.page, .page,
.header, .header,
@@ -5022,3 +5604,46 @@ textarea {
position: absolute !important; position: absolute !important;
z-index: 5000 !important; z-index: 5000 !important;
} }
/* Final width scaling */
.page {
width: min(1680px, calc(100vw - 32px));
max-width: 1680px;
padding-inline: 16px;
}
@media (max-width: 1280px) {
.page {
width: min(1480px, calc(100vw - 24px));
max-width: 1480px;
padding-inline: 12px;
}
.admin-shell {
grid-template-columns: minmax(200px, 240px) minmax(0, 1fr);
}
.admin-shell-rail {
grid-column: 2;
position: static;
top: auto;
}
}
@media (max-width: 980px) {
.page {
width: min(100%, calc(100vw - 12px));
max-width: none;
padding-inline: 6px;
}
.admin-shell {
grid-template-columns: 1fr;
}
.admin-shell-nav,
.admin-card,
.admin-shell-rail {
grid-column: 1;
}
}

View File

@@ -5,10 +5,10 @@ export default function HowItWorksPage() {
<main className="card how-page"> <main className="card how-page">
<header className="how-hero"> <header className="how-hero">
<p className="eyebrow">How this works</p> <p className="eyebrow">How this works</p>
<h1>Your request, step by step</h1> <h1>How Magent works now</h1>
<p className="lede"> <p className="lede">
Magent is a friendly status checker. It looks at a few helper apps, then shows you where End-to-end request flow, live status updates, and the exact tools available to users and
your request is and what you can safely do next. admins.
</p> </p>
</header> </header>
@@ -52,90 +52,172 @@ export default function HowItWorksPage() {
</section> </section>
<section className="how-flow"> <section className="how-flow">
<h2>The pipeline in plain English</h2> <h2>The pipeline (request to ready)</h2>
<ol className="how-steps"> <ol className="how-steps">
<li> <li>
<strong>You request a title</strong> in Jellyseerr. <strong>Request created</strong> in Jellyseerr.
</li> </li>
<li> <li>
<strong>Sonarr/Radarr adds it</strong> to the library list. <strong>Approved</strong> and sent to Sonarr/Radarr.
</li> </li>
<li> <li>
<strong>Prowlarr looks for sources</strong> and sends results back. <strong>Search runs</strong> against indexers via Prowlarr.
</li> </li>
<li> <li>
<strong>qBittorrent downloads</strong> the match. <strong>Grabbed</strong> and downloaded by qBittorrent.
</li> </li>
<li> <li>
<strong>Sonarr/Radarr imports</strong> it into your library. <strong>Imported</strong> by Sonarr/Radarr.
</li> </li>
<li> <li>
<strong>Jellyfin shows it</strong> when it is ready to watch. <strong>Available</strong> in Jellyfin.
</li> </li>
</ol> </ol>
</section> </section>
<section className="how-flow"> <section className="how-flow">
<h2>Steps and fixes (simple and visual)</h2> <h2>Live updates (no refresh needed)</h2>
<div className="how-step-grid">
<article className="how-step-card step-arr">
<div className="step-badge">1</div>
<h3>Request page updates in real time</h3>
<p className="step-note">
Status, timeline hops, and action history update automatically while you are viewing
the request.
</p>
</article>
<article className="how-step-card step-qbit">
<div className="step-badge">2</div>
<h3>Download progress updates live</h3>
<p className="step-note">
Torrent progress, queue state, and downloader details refresh automatically so users
do not need to hard refresh.
</p>
</article>
<article className="how-step-card step-jellyfin">
<div className="step-badge">3</div>
<h3>Ready state appears as soon as import finishes</h3>
<p className="step-note">
As soon as Sonarr/Radarr import completes and Jellyfin can serve it, the request page
shows it as ready.
</p>
</article>
</div>
</section>
<section className="how-flow">
<h2>Request actions and when to use them</h2>
<div className="how-step-grid"> <div className="how-step-grid">
<article className="how-step-card step-jellyseerr"> <article className="how-step-card step-jellyseerr">
<div className="step-badge">1</div> <div className="step-badge">1</div>
<h3>Request sent</h3> <h3>Re-add to Arr</h3>
<p className="step-note">Jellyseerr holds your request and approval.</p> <p className="step-note">Use when a request is approved but never entered the Arr queue.</p>
<div className="step-fix-title">Fixes you can try</div> <div className="step-fix-title">Best for</div>
<ul className="step-fix-list"> <ul className="step-fix-list">
<li>Add to library queue (if it was approved but never added)</li> <li>Missing NEEDS_ADD / ADDED state transitions</li>
<li>Queue repair after Arr-side cleanup</li>
</ul> </ul>
</article> </article>
<article className="how-step-card step-arr"> <article className="how-step-card step-arr">
<div className="step-badge">2</div> <div className="step-badge">2</div>
<h3>Added to the library list</h3> <h3>Search releases</h3>
<p className="step-note">Sonarr/Radarr decide what quality to get.</p> <p className="step-note">Runs a search and shows concrete release options.</p>
<div className="step-fix-title">Fixes you can try</div> <div className="step-fix-title">Best for</div>
<ul className="step-fix-list"> <ul className="step-fix-list">
<li>Search for releases (see options)</li> <li>Manual selection of a specific release/indexer</li>
<li>Search and auto-download (let it pick for you)</li> <li>Checking whether results currently exist</li>
</ul> </ul>
</article> </article>
<article className="how-step-card step-prowlarr"> <article className="how-step-card step-prowlarr">
<div className="step-badge">3</div> <div className="step-badge">3</div>
<h3>Searching for sources</h3> <h3>Search + auto-download</h3>
<p className="step-note">Prowlarr checks your torrent providers.</p> <p className="step-note">Runs search and lets Arr pick/grab automatically.</p>
<div className="step-fix-title">Fixes you can try</div> <div className="step-fix-title">Best for</div>
<ul className="step-fix-list"> <ul className="step-fix-list">
<li>Search for releases (show a list to choose)</li> <li>Fast recovery when users have auto-search access</li>
<li>Hands-off retry of stalled requests</li>
</ul> </ul>
</article> </article>
<article className="how-step-card step-qbit"> <article className="how-step-card step-qbit">
<div className="step-badge">4</div> <div className="step-badge">4</div>
<h3>Downloading the file</h3> <h3>Resume download</h3>
<p className="step-note">qBittorrent downloads the selected match.</p> <p className="step-note">Resumes a paused/stopped torrent in qBittorrent.</p>
<div className="step-fix-title">Fixes you can try</div> <div className="step-fix-title">Best for</div>
<ul className="step-fix-list"> <ul className="step-fix-list">
<li>Resume download (only if it already exists there)</li> <li>Paused queue entries</li>
<li>Downloader restarts</li>
</ul> </ul>
</article> </article>
<article className="how-step-card step-jellyfin"> <article className="how-step-card step-jellyfin">
<div className="step-badge">5</div> <div className="step-badge">5</div>
<h3>Ready to watch</h3> <h3>Open in Jellyfin</h3>
<p className="step-note">Jellyfin shows it in your library.</p> <p className="step-note">Available when the item is imported and linked to Jellyfin.</p>
<div className="step-fix-title">What to do next</div> <div className="step-fix-title">Best for</div>
<ul className="step-fix-list"> <ul className="step-fix-list">
<li>Open in Jellyfin (watch it)</li> <li>Immediate playback confirmation</li>
<li>User handoff from request tracking to watching</li>
</ul> </ul>
</article> </article>
</div> </div>
</section> </section>
<section className="how-flow">
<h2>Invite and account flow</h2>
<ol className="how-steps">
<li>
<strong>Invite created</strong> by admin or eligible user.
</li>
<li>
<strong>User signs up</strong> and Magent creates/links the account.
</li>
<li>
<strong>Profile/defaults apply</strong> (role, auto-search, expiry, invite access).
</li>
<li>
<strong>Admin trace map</strong> can show inviter invited lineage.
</li>
</ol>
</section>
<section className="how-flow">
<h2>Admin controls available</h2>
<div className="how-grid">
<article className="how-card">
<h3>General</h3>
<p>App URL/port, API URL/port, bind host, proxy base URL, and manual SSL bind options.</p>
</article>
<article className="how-card">
<h3>Notifications</h3>
<p>Email, Discord, Telegram, push/mobile, and generic webhook provider settings.</p>
</article>
<article className="how-card">
<h3>Users</h3>
<p>Bulk auto-search control, invite access control, per-user roles/profile/expiry, and system actions.</p>
</article>
<article className="how-card">
<h3>Invite management</h3>
<p>Profiles, invites, blanket rules, master template, and trace map (list/graph with lineage).</p>
</article>
<article className="how-card">
<h3>Request sync + cache</h3>
<p>Control refresh/sync behavior, view all requests, and manage cached request records.</p>
</article>
<article className="how-card">
<h3>Maintenance + logs</h3>
<p>Run cleanup/sync tasks, inspect operations, and diagnose pipeline issues quickly.</p>
</article>
</div>
</section>
<section className="how-callout"> <section className="how-callout">
<h2>Why Magent sometimes says &quot;waiting&quot;</h2> <h2>Why a request can still wait</h2>
<p> <p>
If the search helper cannot find a match yet, Magent will say there is nothing to grab. If indexers do not return a valid release yet, Magent will show waiting/search states.
That does not mean it is broken. It usually means the release is not available yet. That usually means content availability is the blocker, not a broken pipeline.
</p> </p>
</section> </section>
</main> </main>

View File

@@ -7,10 +7,11 @@ type AdminShellProps = {
title: string title: string
subtitle?: string subtitle?: string
actions?: ReactNode actions?: ReactNode
rail?: ReactNode
children: ReactNode children: ReactNode
} }
export default function AdminShell({ title, subtitle, actions, children }: AdminShellProps) { export default function AdminShell({ title, subtitle, actions, rail, children }: AdminShellProps) {
return ( return (
<div className="admin-shell"> <div className="admin-shell">
<aside className="admin-shell-nav"> <aside className="admin-shell-nav">
@@ -26,6 +27,16 @@ export default function AdminShell({ title, subtitle, actions, children }: Admin
</div> </div>
{children} {children}
</main> </main>
<aside className="admin-shell-rail">
{rail ?? (
<div className="admin-rail-card admin-rail-card--placeholder">
<span className="admin-rail-eyebrow">Insights</span>
<h2>Stats rail</h2>
<p>Use this column for counters, live status, and quick metrics for this page.</p>
<span className="small-pill">{title}</span>
</div>
)}
</aside>
</div> </div>
) )
} }

View File

@@ -6,7 +6,7 @@ const NAV_GROUPS = [
{ {
title: 'Services', title: 'Services',
items: [ items: [
{ href: '/admin/magent', label: 'Magent' }, { href: '/admin/general', label: 'General' },
{ href: '/admin/jellyseerr', label: 'Jellyseerr' }, { href: '/admin/jellyseerr', label: 'Jellyseerr' },
{ href: '/admin/jellyfin', label: 'Jellyfin' }, { href: '/admin/jellyfin', label: 'Jellyfin' },
{ href: '/admin/sonarr', label: 'Sonarr' }, { href: '/admin/sonarr', label: 'Sonarr' },
@@ -26,8 +26,8 @@ const NAV_GROUPS = [
{ {
title: 'Admin', title: 'Admin',
items: [ items: [
{ href: '/admin/general', label: 'General' },
{ href: '/admin/notifications', label: 'Notifications' }, { href: '/admin/notifications', label: 'Notifications' },
{ href: '/admin/system', label: 'System guide' },
{ href: '/admin/site', label: 'Site' }, { href: '/admin/site', label: 'Site' },
{ href: '/users', label: 'Users' }, { href: '/users', label: 'Users' },
{ href: '/admin/invites', label: 'Invite management' }, { href: '/admin/invites', label: 'Invite management' },

View File

@@ -250,78 +250,96 @@ export default function UsersPage() {
filteredUsers.length === users.length filteredUsers.length === users.length
? `${users.length} users` ? `${users.length} users`
: `${filteredUsers.length} of ${users.length} users` : `${filteredUsers.length} of ${users.length} users`
const usersRail = (
<div className="admin-rail-stack">
<div className="admin-rail-card users-rail-summary">
<div className="user-directory-panel-header">
<div>
<h2>Directory summary</h2>
<p className="lede">A quick view of user access and account state.</p>
</div>
</div>
<div className="users-summary-grid">
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Total users</span>
<strong className="users-summary-value">{users.length}</strong>
</div>
<p className="users-summary-meta">{adminCount} admin accounts</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Auto search</span>
<strong className="users-summary-value">{autoSearchEnabledCount}</strong>
</div>
<p className="users-summary-meta">of {nonAdminUsers.length} non-admin users enabled</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Blocked</span>
<strong className="users-summary-value">{blockedCount}</strong>
</div>
<p className="users-summary-meta">
{blockedCount ? 'Accounts currently blocked' : 'No blocked users'}
</p>
</div>
<div className="users-summary-card">
<div className="users-summary-row">
<span className="users-summary-label">Expired</span>
<strong className="users-summary-value">{expiredCount}</strong>
</div>
<p className="users-summary-meta">
{expiredCount ? 'Accounts with expired access' : 'No expiries'}
</p>
</div>
</div>
</div>
</div>
)
return ( return (
<AdminShell <AdminShell
title="Users" title="Users"
subtitle="Directory, access status, and request activity." subtitle="Directory, access status, and request activity."
actions={ rail={usersRail}
<div className="admin-inline-actions"> >
<button type="button" className="ghost-button" onClick={() => router.push('/admin/invites')}> <section className="admin-section">
<div className="admin-panel users-page-toolbar">
<div className="users-page-toolbar-grid">
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Directory actions</span>
<div className="users-page-toolbar-actions">
<button
type="button"
className="ghost-button"
onClick={() => router.push('/admin/invites')}
>
Invite management Invite management
</button> </button>
<button type="button" onClick={loadUsers}> <button type="button" onClick={loadUsers}>
Reload list Reload list
</button> </button>
</div>
</div>
<div className="users-page-toolbar-group">
<span className="users-page-toolbar-label">Jellyseerr sync</span>
<div className="users-page-toolbar-actions">
<button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}> <button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
{jellyseerrSyncBusy ? 'Syncing Jellyseerr users...' : 'Sync Jellyseerr users'} {jellyseerrSyncBusy ? 'Syncing Jellyseerr users...' : 'Sync Jellyseerr users'}
</button> </button>
<button type="button" onClick={resyncJellyseerrUsers} disabled={jellyseerrResyncBusy}> <button
type="button"
onClick={resyncJellyseerrUsers}
disabled={jellyseerrResyncBusy}
>
{jellyseerrResyncBusy ? 'Resyncing Jellyseerr users...' : 'Resync Jellyseerr users'} {jellyseerrResyncBusy ? 'Resyncing Jellyseerr users...' : 'Resync Jellyseerr users'}
</button> </button>
</div> </div>
} </div>
> </div>
<section className="admin-section"> </div>
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>} {jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
<div className="admin-summary-grid user-summary-grid">
<div className="admin-summary-tile">
<span className="label">Total users</span>
<strong>{users.length}</strong>
<small>{adminCount} admin</small>
</div>
<div className="admin-summary-tile">
<span className="label">Auto search</span>
<strong>{autoSearchEnabledCount}</strong>
<small>of {nonAdminUsers.length} non-admin users</small>
</div>
<div className="admin-summary-tile">
<span className="label">Blocked</span>
<strong>{blockedCount}</strong>
<small>{blockedCount ? 'Needs review' : 'No blocked users'}</small>
</div>
<div className="admin-summary-tile">
<span className="label">Expired</span>
<strong>{expiredCount}</strong>
<small>{expiredCount ? 'Access expired' : 'No expiries'}</small>
</div>
</div>
<div className="user-directory-control-grid">
<div className="admin-panel user-directory-search-panel">
<div className="user-directory-panel-header">
<div>
<h2>Directory search</h2>
<p className="lede">
Filter by username, role, login provider, or assigned profile.
</p>
</div>
<span className="small-pill">{filteredCountLabel}</span>
</div>
<div className="user-directory-toolbar">
<div className="user-directory-search">
<label>
<span className="user-bulk-label">Search users</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search username, login type, role, profile…"
/>
</label>
</div>
</div>
</div>
<div className="admin-panel user-directory-bulk-panel"> <div className="admin-panel user-directory-bulk-panel">
<div className="user-directory-panel-header"> <div className="user-directory-panel-header">
<div> <div>
@@ -357,6 +375,28 @@ export default function UsersPage() {
</div> </div>
</div> </div>
</div> </div>
<div className="admin-panel user-directory-search-panel">
<div className="user-directory-panel-header">
<div>
<h2>Directory search</h2>
<p className="lede">
Filter by username, role, login provider, or assigned profile.
</p>
</div>
<span className="small-pill">{filteredCountLabel}</span>
</div>
<div className="user-directory-toolbar">
<div className="user-directory-search">
<label>
<span className="user-bulk-label">Search users</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search username, login type, role, profile…"
/>
</label>
</div>
</div>
</div> </div>
{filteredUsers.length === 0 ? ( {filteredUsers.length === 0 ? (
<div className="status-banner">No users found yet.</div> <div className="status-banner">No users found yet.</div>

View File

@@ -1,7 +1,7 @@
{ {
"name": "magent-frontend", "name": "magent-frontend",
"private": true, "private": true,
"version": "0202261541", "version": "2702261314",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",