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

@@ -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) {
return <main className="card">Loading admin settings...</main>
}
@@ -1270,6 +1350,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<AdminShell
title={SECTION_LABELS[section] ?? 'Settings'}
subtitle={SECTION_DESCRIPTIONS[section] ?? 'Manage settings.'}
rail={cacheRail}
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
@@ -1893,32 +1974,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<section className="admin-section" id="cache">
<div className="section-header">
<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>
{cacheStatus && <div className="error-banner">{cacheStatus}</div>}
<div className="cache-table">
<div className="cache-row cache-head">
<span>Request</span>

View File

@@ -70,6 +70,21 @@ type ProfileForm = {
}
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 = {
master_invite_id?: number | null
@@ -105,6 +120,9 @@ const formatDate = (value?: string | null) => {
return date.toLocaleString()
}
const isInviteTraceRowInvited = (row: InviteTraceRow) =>
Boolean(String(row.inviterUsername || '').trim() || String(row.inviteCode || '').trim())
export default function AdminInviteManagementPage() {
const router = useRouter()
const [invites, setInvites] = useState<Invite[]>([])
@@ -135,6 +153,8 @@ export default function AdminInviteManagementPage() {
const [invitePolicy, setInvitePolicy] = useState<InvitePolicy | null>(null)
const [activeTab, setActiveTab] = useState<InviteManagementTab>('bulk')
const [traceFilter, setTraceFilter] = useState('')
const [traceScope, setTraceScope] = useState<InviteTraceScope>('all')
const [traceView, setTraceView] = useState<InviteTraceView>('graph')
const signupBaseUrl = useMemo(() => {
if (typeof window === 'undefined') return '/signup'
@@ -698,74 +718,116 @@ export default function AdminInviteManagementPage() {
)
}, [invites, traceFilter, users])
const scopedInviteTraceRows = useMemo(() => {
if (traceScope === 'invited') return inviteTraceRows.filter((row) => isInviteTraceRowInvited(row))
if (traceScope === 'direct') return inviteTraceRows.filter((row) => !isInviteTraceRowInvited(row))
return inviteTraceRows
}, [inviteTraceRows, traceScope])
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>
<span className="admin-rail-eyebrow">Overview</span>
<h2>Invite stats</h2>
<p className="lede">Live counts for invites, profiles, and managed user defaults.</p>
</div>
</div>
<div className="invite-admin-summary-list">
<div className="invite-admin-summary-row">
<span className="label">Invites</span>
<div className="invite-admin-summary-row__value">
<strong>{invites.length}</strong>
<span>{usableInvites} usable {disabledInvites} disabled</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Profiles</span>
<div className="invite-admin-summary-row__value">
<strong>{profiles.length}</strong>
<span>{activeProfiles} active profiles</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Local non-admin accounts</span>
<div className="invite-admin-summary-row__value">
<strong>{nonAdminUsers.length}</strong>
<span>{profiledUsers} with profile</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Jellyfin users</span>
<div className="invite-admin-summary-row__value">
<strong>{jellyfinUsersCount ?? '—'}</strong>
<span>
{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Self-service invites</span>
<div className="invite-admin-summary-row__value">
<strong>{inviteAccessEnabledUsers}</strong>
<span>
{masterInvite
? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}`
: 'users enabled • no master template set'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Expiry rules</span>
<div className="invite-admin-summary-row__value">
<strong>{expiringUsers}</strong>
<span>users with custom expiry</span>
</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="admin-panel invite-admin-summary-panel">
<div className="invite-admin-summary-header">
<div>
<h2>Overview</h2>
<p className="lede">
Quick counts for invite links, profiles, and managed user defaults.
</p>
</div>
</div>
<div className="invite-admin-summary-list">
<div className="invite-admin-summary-row">
<span className="label">Invites</span>
<div className="invite-admin-summary-row__value">
<strong>{invites.length}</strong>
<span>{usableInvites} usable {disabledInvites} disabled</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Profiles</span>
<div className="invite-admin-summary-row__value">
<strong>{profiles.length}</strong>
<span>{activeProfiles} active profiles</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Local non-admin accounts</span>
<div className="invite-admin-summary-row__value">
<strong>{nonAdminUsers.length}</strong>
<span>{profiledUsers} with profile</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Jellyfin users</span>
<div className="invite-admin-summary-row__value">
<strong>{jellyfinUsersCount ?? '—'}</strong>
<span>{jellyfinUsersCount == null ? 'Unavailable/not configured' : 'Current Jellyfin user objects'}</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Self-service invites</span>
<div className="invite-admin-summary-row__value">
<strong>{inviteAccessEnabledUsers}</strong>
<span>
{masterInvite
? `users enabled • master template ${masterInvite.code ?? `#${masterInvite.id}`}`
: 'users enabled • no master template set'}
</span>
</div>
</div>
<div className="invite-admin-summary-row">
<span className="label">Expiry rules</span>
<div className="invite-admin-summary-row__value">
<strong>{expiringUsers}</strong>
<span>users with custom expiry</span>
</div>
</div>
</div>
</div>
<div className="invite-admin-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
<button
@@ -833,8 +895,8 @@ export default function AdminInviteManagementPage() {
</div>
{activeTab === 'bulk' && (
<div className="admin-split-grid invite-admin-bulk-grid">
<div className="admin-panel">
<div className="invite-admin-stack">
<div className="admin-panel invite-admin-bulk-panel">
<div className="user-directory-panel-header">
<div>
<h2>Blanket controls</h2>
@@ -843,13 +905,6 @@ export default function AdminInviteManagementPage() {
</p>
</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-group">
<div className="user-bulk-group-meta">
@@ -961,46 +1016,6 @@ export default function AdminInviteManagementPage() {
</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>
)}
@@ -1410,19 +1425,105 @@ export default function AdminInviteManagementPage() {
placeholder="Search by username, inviter, or invite code"
/>
</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">
<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>{invites.length} invites loaded</span>
</div>
</div>
{loading ? (
<div className="status-banner">Loading trace map</div>
) : inviteTraceRows.length === 0 ? (
) : scopedInviteTraceRows.length === 0 ? (
<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">
{inviteTraceRows.map((row) => (
{scopedInviteTraceRows.map((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` }}>
<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>
)
}