Build 2602261523: live updates, invite cleanup and nuclear resync

This commit is contained in:
2026-02-26 15:24:10 +13:00
parent 5dfe614d15
commit 50be0b6b57
12 changed files with 939 additions and 230 deletions

View File

@@ -1,6 +1,6 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell'
@@ -141,6 +141,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
const requestsSyncRef = useRef<any | null>(null)
const artworkPrefetchRef = useRef<any | null>(null)
const loadSettings = useCallback(async () => {
const baseUrl = getApiBase()
@@ -338,6 +341,14 @@ export default function SettingsPage({ section }: SettingsPageProps) {
return false
}
useEffect(() => {
requestsSyncRef.current = requestsSync
}, [requestsSync])
useEffect(() => {
artworkPrefetchRef.current = artworkPrefetch
}, [artworkPrefetch])
const settingDescriptions: Record<string, string> = {
jellyseerr_base_url:
'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.',
@@ -576,7 +587,100 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status !== 'running') {
const shouldSubscribe = showRequestsExtras || showArtworkExtras || showLogs
if (!shouldSubscribe) {
setLiveStreamConnected(false)
return
}
const token = getToken()
if (!token) {
setLiveStreamConnected(false)
return
}
const baseUrl = getApiBase()
const params = new URLSearchParams()
params.set('access_token', token)
if (showLogs) {
params.set('include_logs', '1')
params.set('log_lines', String(logsCount))
}
const streamUrl = `${baseUrl}/admin/events/stream?${params.toString()}`
let closed = false
const source = new EventSource(streamUrl)
source.onopen = () => {
if (closed) return
setLiveStreamConnected(true)
}
source.onmessage = (event) => {
if (closed) return
setLiveStreamConnected(true)
try {
const payload = JSON.parse(event.data)
if (!payload || payload.type !== 'admin_live_state') {
return
}
const rawSync =
payload.requestsSync && typeof payload.requestsSync === 'object'
? payload.requestsSync
: null
const nextSync = rawSync?.status === 'idle' ? null : rawSync
const prevSync = requestsSyncRef.current
requestsSyncRef.current = nextSync
setRequestsSync(nextSync)
if (prevSync?.status === 'running' && nextSync?.status && nextSync.status !== 'running') {
setRequestsSyncStatus(nextSync.message || 'Sync complete.')
}
const rawArtwork =
payload.artworkPrefetch && typeof payload.artworkPrefetch === 'object'
? payload.artworkPrefetch
: null
const nextArtwork = rawArtwork?.status === 'idle' ? null : rawArtwork
const prevArtwork = artworkPrefetchRef.current
artworkPrefetchRef.current = nextArtwork
setArtworkPrefetch(nextArtwork)
if (
prevArtwork?.status === 'running' &&
nextArtwork?.status &&
nextArtwork.status !== 'running'
) {
setArtworkPrefetchStatus(nextArtwork.message || 'Artwork caching complete.')
if (showArtworkExtras) {
void loadArtworkSummary()
}
}
if (payload.logs && typeof payload.logs === 'object') {
if (Array.isArray(payload.logs.lines)) {
setLogsLines(payload.logs.lines)
setLogsStatus(null)
} else if (typeof payload.logs.error === 'string' && payload.logs.error.trim()) {
setLogsStatus(payload.logs.error)
}
}
} catch (err) {
console.error(err)
}
}
source.onerror = () => {
if (closed) return
setLiveStreamConnected(false)
}
return () => {
closed = true
setLiveStreamConnected(false)
source.close()
}
}, [loadArtworkSummary, logsCount, showArtworkExtras, showLogs, showRequestsExtras])
useEffect(() => {
if (liveStreamConnected || !artworkPrefetch || artworkPrefetch.status !== 'running') {
return
}
let active = true
@@ -602,7 +706,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
}, [artworkPrefetch, loadArtworkSummary])
}, [artworkPrefetch, liveStreamConnected, loadArtworkSummary])
useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') {
@@ -615,7 +719,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [artworkPrefetch])
useEffect(() => {
if (!requestsSync || requestsSync.status !== 'running') {
if (liveStreamConnected || !requestsSync || requestsSync.status !== 'running') {
return
}
let active = true
@@ -640,7 +744,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false
clearInterval(timer)
}
}, [requestsSync])
}, [liveStreamConnected, requestsSync])
useEffect(() => {
if (!requestsSync || requestsSync.status === 'running') {
@@ -683,12 +787,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (!showLogs) {
return
}
if (liveStreamConnected) {
return
}
void loadLogs()
const timer = setInterval(() => {
void loadLogs()
}, 5000)
return () => clearInterval(timer)
}, [loadLogs, showLogs])
}, [liveStreamConnected, loadLogs, showLogs])
const loadCache = async () => {
setCacheStatus(null)
@@ -763,7 +870,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setMaintenanceBusy(true)
if (typeof window !== 'undefined') {
const ok = window.confirm(
'This will clear cached requests and history, then re-sync from Jellyseerr. Continue?'
'This will perform a nuclear reset: clear cached requests/history, wipe non-admin users, invites, and profiles, then re-sync users and requests from Jellyseerr. Continue?'
)
if (!ok) {
setMaintenanceBusy(false)
@@ -772,7 +879,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
try {
const baseUrl = getApiBase()
setMaintenanceStatus('Flushing database...')
setMaintenanceStatus('Running nuclear flush...')
const flushResponse = await authFetch(`${baseUrl}/admin/maintenance/flush`, {
method: 'POST',
})
@@ -780,12 +887,25 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const text = await flushResponse.text()
throw new Error(text || 'Flush failed')
}
setMaintenanceStatus('Database flushed. Starting re-sync...')
const flushData = await flushResponse.json()
const usersCleared = Number(flushData?.userObjectsCleared?.users ?? 0)
setMaintenanceStatus(`Nuclear flush complete. Cleared ${usersCleared} non-admin users. Re-syncing users...`)
const usersResyncResponse = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
method: 'POST',
})
if (!usersResyncResponse.ok) {
const text = await usersResyncResponse.text()
throw new Error(text || 'User resync failed')
}
const usersResyncData = await usersResyncResponse.json()
setMaintenanceStatus(
`Users re-synced (${usersResyncData?.imported ?? 0} imported). Starting request re-sync...`
)
await syncRequests()
setMaintenanceStatus('Database flushed. Re-sync running now.')
setMaintenanceStatus('Nuclear flush complete. User and request re-sync running now.')
} catch (err) {
console.error(err)
setMaintenanceStatus('Flush + resync failed.')
setMaintenanceStatus('Nuclear flush + resync failed.')
} finally {
setMaintenanceBusy(false)
}
@@ -1452,7 +1572,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<h2>Maintenance</h2>
</div>
<div className="status-banner">
Emergency tools. Use with care: flush will clear saved requests and history.
Emergency tools. Use with care: flush + resync now performs a nuclear wipe of non-admin users, invite links, profiles, cached requests, and history before re-syncing Jellyseerr users/requests.
</div>
{maintenanceStatus && <div className="status-banner">{maintenanceStatus}</div>}
<div className="maintenance-grid">
@@ -1471,7 +1591,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
onClick={runFlushAndResync}
disabled={maintenanceBusy}
>
Flush database + resync
Nuclear flush + resync
</button>
</div>
</section>

View File

@@ -477,26 +477,6 @@ export default function AdminInviteManagementPage() {
<button type="button" onClick={loadData} disabled={loading}>
{loading ? 'Loading…' : 'Reload'}
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
resetInviteEditor()
setActiveTab('invites')
}}
>
New invite
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
resetProfileEditor()
setActiveTab('profiles')
}}
>
New profile
</button>
</div>
}
>
@@ -504,57 +484,99 @@ export default function AdminInviteManagementPage() {
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<div className="invite-admin-summary-grid">
<div className="admin-summary-tile invite-admin-summary-tile">
<span className="label">Invites</span>
<strong>{invites.length}</strong>
<small>{usableInvites} usable {disabledInvites} disabled</small>
<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="admin-summary-tile invite-admin-summary-tile">
<span className="label">Profiles</span>
<strong>{profiles.length}</strong>
<small>{activeProfiles} active profiles</small>
</div>
<div className="admin-summary-tile invite-admin-summary-tile">
<span className="label">Non-admin users</span>
<strong>{nonAdminUsers.length}</strong>
<small>{profiledUsers} with profile</small>
</div>
<div className="admin-summary-tile invite-admin-summary-tile">
<span className="label">Expiry rules</span>
<strong>{expiringUsers}</strong>
<small>users with custom expiry</small>
<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">Non-admin users</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">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="admin-segmented" role="tablist" aria-label="Invite management sections">
<button
type="button"
role="tab"
aria-selected={activeTab === 'bulk'}
className={activeTab === 'bulk' ? 'is-active' : ''}
onClick={() => setActiveTab('bulk')}
>
Blanket controls
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'profiles'}
className={activeTab === 'profiles' ? 'is-active' : ''}
onClick={() => setActiveTab('profiles')}
>
Profiles
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'invites'}
className={activeTab === 'invites' ? 'is-active' : ''}
onClick={() => setActiveTab('invites')}
>
Invites
</button>
<div className="invite-admin-tabbar">
<div className="admin-segmented" role="tablist" aria-label="Invite management sections">
<button
type="button"
role="tab"
aria-selected={activeTab === 'bulk'}
className={activeTab === 'bulk' ? 'is-active' : ''}
onClick={() => setActiveTab('bulk')}
>
Blanket controls
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'profiles'}
className={activeTab === 'profiles' ? 'is-active' : ''}
onClick={() => setActiveTab('profiles')}
>
Profiles
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'invites'}
className={activeTab === 'invites' ? 'is-active' : ''}
onClick={() => setActiveTab('invites')}
>
Invites
</button>
</div>
<div className="admin-inline-actions invite-admin-tab-actions">
<button
type="button"
className="ghost-button"
onClick={() => {
resetInviteEditor()
setActiveTab('invites')
}}
>
New invite
</button>
<button
type="button"
className="ghost-button"
onClick={() => {
resetProfileEditor()
setActiveTab('profiles')
}}
>
New profile
</button>
</div>
</div>
{activeTab === 'bulk' && (
@@ -815,116 +837,151 @@ export default function AdminInviteManagementPage() {
<p className="lede">
Link an invite to a profile to apply account defaults at sign-up.
</p>
<form onSubmit={saveInvite} className="admin-form compact-form">
<div className="admin-fields-grid">
<label>
Code (optional)
<input
value={inviteForm.code}
onChange={(e) =>
setInviteForm((current) => ({ ...current, code: e.target.value }))
}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
Label
<input
value={inviteForm.label}
onChange={(e) =>
setInviteForm((current) => ({ ...current, label: e.target.value }))
}
placeholder="Staff invite batch"
/>
</label>
<form onSubmit={saveInvite} className="admin-form compact-form invite-form-layout">
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Identity</span>
<small>Code and label used to identify the invite link.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Code (optional)</span>
<input
value={inviteForm.code}
onChange={(e) =>
setInviteForm((current) => ({ ...current, code: e.target.value }))
}
placeholder="Leave blank to auto-generate"
/>
</label>
<label>
<span>Label</span>
<input
value={inviteForm.label}
onChange={(e) =>
setInviteForm((current) => ({ ...current, label: e.target.value }))
}
placeholder="Staff invite batch"
/>
</label>
</div>
</div>
<label>
Description
<textarea
rows={3}
value={inviteForm.description}
onChange={(e) =>
setInviteForm((current) => ({ ...current, description: e.target.value }))
}
placeholder="Optional note shown on the signup page"
/>
</label>
<div className="admin-fields-grid">
<label>
Profile
<select
value={inviteForm.profile_id}
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Description</span>
<small>Optional note shown on the signup page.</small>
</div>
<div className="invite-form-row-control">
<textarea
rows={3}
value={inviteForm.description}
onChange={(e) =>
setInviteForm((current) => ({ ...current, profile_id: e.target.value }))
setInviteForm((current) => ({ ...current, description: e.target.value }))
}
>
<option value="">None</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<label>
Role override
<select
value={inviteForm.role}
onChange={(e) =>
setInviteForm((current) => ({
...current,
role: e.target.value as '' | 'user' | 'admin',
}))
}
>
<option value="">Use profile/default</option>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
</div>
<div className="admin-fields-grid">
<label>
Max uses
<input
value={inviteForm.max_uses}
onChange={(e) =>
setInviteForm((current) => ({ ...current, max_uses: e.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
placeholder="Optional note shown on the signup page"
/>
</label>
<label>
Invite expiry (ISO datetime)
<input
value={inviteForm.expires_at}
onChange={(e) =>
setInviteForm((current) => ({ ...current, expires_at: e.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
/>
</label>
</div>
</div>
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(e) =>
setInviteForm((current) => ({ ...current, enabled: e.target.checked }))
}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={inviteSaving}>
{inviteSaving ? 'Saving…' : inviteEditingId == null ? 'Create invite' : 'Save invite'}
</button>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Defaults</span>
<small>Choose a profile and optional role override for sign-up.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Profile</span>
<select
value={inviteForm.profile_id}
onChange={(e) =>
setInviteForm((current) => ({ ...current, profile_id: e.target.value }))
}
>
<option value="">None</option>
{profiles.map((profile) => (
<option key={profile.id} value={profile.id}>
{profile.name}{profile.is_active === false ? ' (disabled)' : ''}
</option>
))}
</select>
</label>
<label>
<span>Role override</span>
<select
value={inviteForm.role}
onChange={(e) =>
setInviteForm((current) => ({
...current,
role: e.target.value as '' | 'user' | 'admin',
}))
}
>
<option value="">Use profile/default</option>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Limits</span>
<small>Usage cap and optional expiry date/time for the invite.</small>
</div>
<div className="invite-form-row-control invite-form-row-grid">
<label>
<span>Max uses</span>
<input
value={inviteForm.max_uses}
onChange={(e) =>
setInviteForm((current) => ({ ...current, max_uses: e.target.value }))
}
inputMode="numeric"
placeholder="Blank = unlimited"
/>
</label>
<label>
<span>Invite expiry (ISO datetime)</span>
<input
value={inviteForm.expires_at}
onChange={(e) =>
setInviteForm((current) => ({ ...current, expires_at: e.target.value }))
}
placeholder="2026-03-01T12:00:00+00:00"
/>
</label>
</div>
</div>
<div className="invite-form-row">
<div className="invite-form-row-label">
<span>Status</span>
<small>Enable or disable the invite before sharing.</small>
</div>
<div className="invite-form-row-control invite-form-row-control--stacked">
<label className="inline-checkbox">
<input
type="checkbox"
checked={inviteForm.enabled}
onChange={(e) =>
setInviteForm((current) => ({ ...current, enabled: e.target.checked }))
}
/>
Invite is enabled
</label>
<div className="admin-inline-actions">
<button type="submit" disabled={inviteSaving}>
{inviteSaving ? 'Saving…' : inviteEditingId == null ? 'Create invite' : 'Save invite'}
</button>
{inviteEditingId != null && (
<button type="button" className="ghost-button" onClick={resetInviteEditor}>
Cancel edit
</button>
)}
</div>
</div>
</div>
</form>
</div>