feat: add Apprise sidecar user/admin notifications

This commit is contained in:
2026-02-25 22:54:18 +13:00
parent d045dd0b07
commit 1fe4a44eb5
9 changed files with 748 additions and 4 deletions

View File

@@ -27,6 +27,7 @@ const SECTION_LABELS: Record<string, string> = {
radarr: 'Radarr',
prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent',
apprise: 'Apprise',
log: 'Activity log',
requests: 'Request sync',
site: 'Site',
@@ -42,6 +43,7 @@ const URL_SETTINGS = new Set([
'radarr_base_url',
'prowlarr_base_url',
'qbittorrent_base_url',
'apprise_base_url',
])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
@@ -54,6 +56,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
radarr: 'Movie automation settings.',
prowlarr: 'Indexer search settings.',
qbittorrent: 'Downloader connection settings.',
apprise: 'Configure the external Apprise sidecar used for notifications.',
requests: 'Control how often requests are refreshed and cleaned up.',
log: 'Activity log for troubleshooting.',
site: 'Sitewide banner, version, and changelog details.',
@@ -67,6 +70,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
radarr: 'radarr',
prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent',
apprise: 'apprise',
requests: 'requests',
cache: null,
logs: 'log',
@@ -366,6 +370,10 @@ export default function SettingsPage({ section }: SettingsPageProps) {
'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.',
qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.',
apprise_base_url:
'External Apprise API base URL for notifications (for example http://apprise:8000).',
apprise_api_key:
'Optional API key Magent uses when calling your external Apprise service.',
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
requests_poll_interval_seconds:
'How often Magent checks if a full refresh should run.',
@@ -393,6 +401,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878',
prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696',
qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080',
apprise_base_url: 'http://apprise:8000 or https://notify.example.com',
}
const buildSelectOptions = (

View File

@@ -0,0 +1,281 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
type NotificationUser = {
username: string
role?: string | null
authProvider?: string | null
jellyseerrUserId?: number | null
isBlocked?: boolean
notifyEnabled?: boolean
notifyCount?: number
}
type SendResult = {
username: string
status: string
}
const formatStatus = (user: NotificationUser) => {
if (user.isBlocked) return 'Blocked'
if (!user.notifyEnabled) return 'Disabled'
if (user.notifyCount && user.notifyCount > 0) return `Enabled (${user.notifyCount})`
return 'No targets'
}
export default function AdminNotificationsPage() {
const router = useRouter()
const [users, setUsers] = useState<NotificationUser[]>([])
const [selected, setSelected] = useState<Set<string>>(new Set())
const [title, setTitle] = useState('')
const [message, setMessage] = useState('')
const [loading, setLoading] = useState(false)
const [sending, setSending] = useState(false)
const [status, setStatus] = useState<string | null>(null)
const [sendResults, setSendResults] = useState<SendResult[]>([])
const selectedCount = selected.size
const selectableUsers = useMemo(
() => users.filter((user) => user.username && !user.isBlocked),
[users]
)
const load = async () => {
if (!getToken()) {
router.push('/login')
return
}
setLoading(true)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/notifications/users`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
throw new Error('Load failed')
}
const data = await response.json()
const fetched = Array.isArray(data?.users) ? data.users : []
setUsers(fetched)
setSelected(new Set())
} catch (err) {
console.error(err)
setStatus('Unable to load notification targets.')
} finally {
setLoading(false)
}
}
useEffect(() => {
void load()
}, [])
const toggleUser = (username: string) => {
setSelected((current) => {
const next = new Set(current)
if (next.has(username)) {
next.delete(username)
} else {
next.add(username)
}
return next
})
}
const selectAll = () => {
const next = new Set<string>()
for (const user of selectableUsers) {
if (user.username) {
next.add(user.username)
}
}
setSelected(next)
}
const selectEnabled = () => {
const next = new Set<string>()
for (const user of selectableUsers) {
if (user.username && user.notifyEnabled && (user.notifyCount ?? 0) > 0) {
next.add(user.username)
}
}
setSelected(next)
}
const clearSelection = () => {
setSelected(new Set())
}
const send = async () => {
setStatus(null)
setSendResults([])
if (selectedCount === 0) {
setStatus('Select at least one user.')
return
}
if (!message.trim()) {
setStatus('Message cannot be empty.')
return
}
setSending(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/notifications/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
usernames: Array.from(selected),
title: title.trim() || 'Magent admin message',
message: message.trim(),
}),
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Send failed')
}
const data = await response.json()
const results = Array.isArray(data?.results) ? data.results : []
setSendResults(results)
setStatus(
`Sent ${data?.sent ?? 0}. Skipped ${data?.skipped ?? 0}. Failed ${data?.failed ?? 0}.`
)
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Send failed.'
setStatus(message)
} finally {
setSending(false)
}
}
return (
<AdminShell
title="User notifications"
subtitle="Send admin messages to users via their Apprise targets."
actions={
<button type="button" onClick={() => router.push('/admin')}>
Back to settings
</button>
}
>
<section className="admin-section">
<div className="admin-toolbar">
<div className="admin-toolbar-info">
<span>{users.length.toLocaleString()} users</span>
<span>{selectedCount.toLocaleString()} selected</span>
</div>
<div className="admin-toolbar-actions">
<button type="button" onClick={selectAll} disabled={loading}>
Select all
</button>
<button type="button" onClick={selectEnabled} disabled={loading}>
Select enabled
</button>
<button type="button" className="ghost-button" onClick={clearSelection}>
Clear
</button>
</div>
</div>
{loading ? (
<div className="status-banner">Loading notification targets</div>
) : users.length === 0 ? (
<div className="status-banner">No users found.</div>
) : (
<div className="admin-table">
<div className="admin-table-head">
<span>Select</span>
<span>User</span>
<span>Role</span>
<span>Status</span>
</div>
{users.map((user) => {
const username = user.username || 'Unknown'
const isChecked = selected.has(username)
return (
<div key={username} className="admin-table-row">
<span>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleUser(username)}
disabled={!username || user.isBlocked}
/>
</span>
<span>{username}</span>
<span>{user.role || 'user'}</span>
<span>{formatStatus(user)}</span>
</div>
)
})}
</div>
)}
</section>
<section className="admin-section">
<div className="section-header">
<h2>Message</h2>
</div>
<div className="admin-form">
<label>
<span className="label-row">
<span>Title</span>
<span className="meta">Optional</span>
</span>
<input
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Magent admin message"
/>
</label>
<label>
<span className="label-row">
<span>Message</span>
<span className="meta">Required</span>
</span>
<textarea
rows={4}
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Write the message you want to send."
/>
</label>
</div>
{status && <div className="status-banner">{status}</div>}
<div className="admin-actions">
<button type="button" onClick={send} disabled={sending}>
{sending ? 'Sending…' : 'Send message'}
</button>
</div>
{sendResults.length > 0 && (
<div className="admin-table">
<div className="admin-table-head">
<span>User</span>
<span>Result</span>
</div>
{sendResults.map((result) => (
<div key={`${result.username}-${result.status}`} className="admin-table-row">
<span>{result.username}</span>
<span>{result.status}</span>
</div>
))}
</div>
)}
</section>
</AdminShell>
)
}