admin docs and layout refresh, build 2702261314
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
211
frontend/app/admin/system/page.tsx
Normal file
211
frontend/app/admin/system/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user