Simplify settings workspace and fix responsive admin controls
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
export type AdminSetting = { key: string; value: string | null; isSet: boolean; source: string; sensitive: boolean }
|
||||
type Option = { value: string; label: string }
|
||||
type Props = {
|
||||
setting: AdminSetting
|
||||
label: string
|
||||
value: string
|
||||
help?: string
|
||||
placeholder?: string
|
||||
boolean?: boolean
|
||||
numeric?: boolean
|
||||
multiline?: boolean
|
||||
options?: Option[]
|
||||
optionsUnavailable?: boolean
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const SELECTS: Record<string, Option[]> = {
|
||||
log_level: ['DEBUG', 'INFO', 'WARNING', 'ERROR'].map((value) => ({ value, label: value })),
|
||||
issue_confirmation_contact_attempts: Array.from({ length: 11 }, (_, index) => ({ value: String(index), label: index === 0 ? 'None — close when fixed' : String(index) })),
|
||||
issue_confirmation_interval_unit: ['days', 'weeks', 'months'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
|
||||
artwork_cache_mode: [{ value: 'remote', label: 'Load from the internet' }, { value: 'cache', label: 'Store locally' }],
|
||||
site_banner_tone: ['info', 'warning', 'error', 'maintenance'].map((value) => ({ value, label: value[0].toUpperCase() + value.slice(1) })),
|
||||
magent_notify_push_provider: ['ntfy', 'gotify', 'pushover', 'webhook', 'telegram', 'discord'].map((value) => ({ value, label: value })),
|
||||
requests_data_source: [{ value: 'always_js', label: 'Read directly from Seerr' }, { value: 'prefer_cache', label: 'Use saved requests' }],
|
||||
}
|
||||
|
||||
export default function SettingField(props: Props) {
|
||||
const { setting, label, value, help, placeholder, onChange } = props
|
||||
const id = `setting-${setting.key}`
|
||||
const options = props.options ?? SELECTS[setting.key] ?? (setting.key === 'log_http_client_level' || setting.key === 'log_background_sync_level' ? SELECTS.log_level : undefined)
|
||||
const selectedOptions = options && value && !options.some((option) => option.value === value)
|
||||
? [{ value, label: `Current selection (${value})` }, ...options] : options
|
||||
const isTime = setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time'
|
||||
const zeroAllowed = setting.key === 'log_file_backup_count'
|
||||
const minimum = zeroAllowed ? 0 : 1
|
||||
const maximum = setting.key === 'issue_confirmation_interval_value' ? 365 : setting.key.endsWith('_port') ? 65535 : undefined
|
||||
const aria = { id, name: setting.key, 'aria-describedby': help ? `${id}-help` : undefined }
|
||||
|
||||
if (props.boolean) {
|
||||
return (
|
||||
<div className="setting-field setting-switch">
|
||||
<div><label htmlFor={id}>{label}</label>{help && <p id={`${id}-help`}>{help}</p>}</div>
|
||||
<input {...aria} type="checkbox" role="switch" checked={value.toLowerCase() === 'true'} onChange={(event) => onChange(String(event.target.checked))} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`setting-field ${props.multiline ? 'field-span-full' : ''}`}>
|
||||
<label htmlFor={id}>{label}{setting.sensitive && setting.isSet && <small>Saved</small>}</label>
|
||||
{props.optionsUnavailable ? (
|
||||
<select {...aria} disabled value={value}><option value={value}>Save the connection, then reload available options</option></select>
|
||||
) : selectedOptions ? (
|
||||
<select {...aria} value={value} onChange={(event) => onChange(event.target.value)}>
|
||||
{!value && <option value="">Choose an option</option>}
|
||||
{selectedOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
) : props.multiline ? (
|
||||
<textarea {...aria} rows={setting.key.includes('_pem') ? 6 : 3} value={value} placeholder={placeholder} onChange={(event) => onChange(event.target.value)} />
|
||||
) : (
|
||||
<input {...aria} type={setting.sensitive ? 'password' : props.numeric ? 'number' : isTime ? 'time' : 'text'}
|
||||
value={value} min={props.numeric ? minimum : undefined} max={props.numeric ? maximum : undefined} step={props.numeric ? 1 : undefined}
|
||||
autoComplete={setting.sensitive ? 'new-password' : 'off'} spellCheck={false}
|
||||
placeholder={setting.sensitive && setting.isSet ? 'Leave blank to keep the saved value' : placeholder}
|
||||
onChange={(event) => onChange(event.target.value)} />
|
||||
)}
|
||||
{help && <p id={`${id}-help`}>{help}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+113
-794
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
|
||||
export default function SettingsRegion({ title, id, collapsed, children }: { title: string; id: string; collapsed: boolean; children: ReactNode }) {
|
||||
const [open, setOpen] = useState(!collapsed)
|
||||
return (
|
||||
<section id={id} className={`admin-section admin-zone config-subsection ${open ? '' : 'is-collapsed'}`}>
|
||||
{collapsed && <button type="button" className="config-region-toggle" aria-expanded={open} aria-controls={`${id}-content`} onClick={() => setOpen(!open)}><strong>{title}</strong><span>{open ? 'Hide' : 'Configure'} <b aria-hidden="true">{open ? '−' : '+'}</b></span></button>}
|
||||
<div id={`${id}-content`} hidden={!open}>{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/* Settings workspace. Shared Stitch tokens, compact controls and clear regions. */
|
||||
.config-directory { display: grid; gap: 32px; max-width: 1120px; }
|
||||
.config-directory-region { display: grid; gap: 16px; }
|
||||
.config-directory-region header h2 { margin: 0 0 4px; font-size: 18px; }
|
||||
.config-directory-region header p { margin: 0; color: var(--ops-muted); font-size: 13px; }
|
||||
.config-directory-links { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
||||
.config-directory-link { display: flex; align-items: center; gap: 14px; padding: 18px; border: 1px solid var(--ops-line); border-radius: 10px; background: var(--ops-panel); color: var(--ops-text); text-decoration: none; min-width: 0; transition: border-color .15s, background .15s; }
|
||||
.config-directory-link:hover { border-color: var(--ops-primary-2); background: var(--ops-panel-2); }
|
||||
.config-link-icon { flex: 0 0 34px; display: grid; place-items: center; height: 34px; border-radius: 8px; background: var(--ops-primary); color: var(--ops-primary-2); font: 11px "JetBrains Mono", monospace; }
|
||||
.config-link-copy { display: grid; flex: 1; min-width: 0; gap: 4px; }
|
||||
.config-link-copy strong { font-size: 14px; }
|
||||
.config-link-copy small { font-size: 12px; font-weight: 400; color: var(--ops-muted); line-height: 1.5; }
|
||||
.config-link-arrow { color: var(--ops-faint); }
|
||||
.config-connection-badge { flex-shrink: 0; font: 11px "JetBrains Mono", monospace; color: var(--ops-muted); }
|
||||
.config-connection-badge::before { content: ''; display: inline-block; width: 6px; height: 6px; border-radius: 50%; margin-right: 6px; background: currentColor; }
|
||||
.config-connection-badge.is-up { color: var(--ops-green); }
|
||||
.config-connection-badge.is-down { color: var(--ops-red); }
|
||||
.config-connection-badge.is-degraded { color: var(--ops-warn); }
|
||||
.config-advanced-directory { border: 1px solid var(--ops-line); border-radius: 10px; padding: 18px; }
|
||||
.config-advanced-directory > summary { cursor: pointer; color: var(--ops-text); }
|
||||
.config-advanced-directory > summary > span { margin-left: 12px; color: var(--ops-muted); font-size: 12px; }
|
||||
.config-advanced-directory[open] > summary { margin-bottom: 18px; }
|
||||
.config-sidebar-home { display: flex; justify-content: space-between; align-items: center; padding: 4px 10px 20px; font: 600 20px "DM Sans", sans-serif; text-decoration: none; color: var(--ops-primary-2); }
|
||||
.config-sidebar-back { display: block; margin-top: 20px; padding: 12px 10px; color: var(--ops-muted); font-size: 12px; }
|
||||
.config-desktop-navigation { display: grid; gap: 18px; }
|
||||
.admin-sidebar .admin-nav-links a { font: 13px Inter, sans-serif; padding: 8px 10px; min-height: 34px; }
|
||||
.admin-sidebar .admin-nav-title { font-size: 10px; }
|
||||
.config-nav-advanced > summary { cursor: pointer; padding: 8px 10px; font-size: 12px; color: var(--ops-muted); }
|
||||
.config-mobile-picker { display: none; }
|
||||
.admin-card { min-width: 0; }
|
||||
.admin-shell { grid-template-areas: "nav main"; }
|
||||
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main rail"; }
|
||||
.admin-shell--no-rail > .admin-card { width: 100%; max-width: 1280px; }
|
||||
.admin-card .admin-header { margin-bottom: 24px; }
|
||||
.admin-card .admin-header .lede { max-width: 720px; margin: 8px 0 0; font-size: 14px; }
|
||||
.admin-card .admin-header .section-kicker { font-size: 10px; }
|
||||
.config-service-status { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; margin-bottom: 20px; font-size: 12px; color: var(--ops-muted); }
|
||||
.config-service-status button { margin-left: auto; }
|
||||
.admin-form.admin-zone-stack { gap: 16px; }
|
||||
.admin-form .config-subsection { padding: 22px !important; }
|
||||
.config-subsection form { display: grid; gap: 18px; min-width: 0; }
|
||||
.config-subsection .section-header { margin: 0; padding: 0; border: 0; align-items: center; }
|
||||
.config-subsection .section-header h2 { font-size: 18px; padding: 0; }
|
||||
.config-subsection .section-header h2::after { display: none; }
|
||||
.config-subsection .section-subtitle { margin: -10px 0 0; font-size: 12px; line-height: 1.6; }
|
||||
.config-subsection .admin-grid { gap: 20px 24px; }
|
||||
.config-subsection .setting-field { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
||||
.config-subsection .setting-field > label, .config-subsection .setting-switch label { display: flex; align-items: center; gap: 10px; min-height: 0; padding: 0; margin: 0; border: 0; border-radius: 0; background: none; color: var(--ops-text); font: 500 13px Inter, sans-serif; text-transform: none; letter-spacing: 0; }
|
||||
.config-subsection .setting-field label small { color: var(--ops-green); font-size: 11px; font-weight: 400; }
|
||||
.config-subsection .setting-field p { margin: 0; color: var(--ops-muted); font: 400 12px/1.5 Inter, sans-serif; text-transform: none; }
|
||||
.config-subsection .setting-field input:not([type=checkbox]), .config-subsection .setting-field select, .config-subsection .setting-field textarea { margin: 0; width: 100%; min-width: 0; padding: 10px 12px; min-height: 42px; border: 1px solid var(--ops-line); background: var(--ops-bg-2); color: var(--ops-text); font: 400 13px/1.5 Inter, sans-serif; border-radius: 8px; }
|
||||
.config-subsection .setting-field input:focus-visible, .config-subsection .setting-field select:focus-visible, .config-subsection .setting-field textarea:focus-visible { outline: 2px solid var(--ops-primary-2); outline-offset: 2px; }
|
||||
.config-subsection .setting-field.field-span-full { grid-column: 1 / -1; }
|
||||
.config-subsection .setting-switch { flex-direction: row; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid var(--ops-line-soft); gap: 24px; }
|
||||
.setting-switch > div { display: grid; gap: 6px; }
|
||||
.config-subsection .setting-switch input[type=checkbox] { appearance: none; -webkit-appearance: none; flex: 0 0 38px; width: 38px; height: 22px; min-height: 22px; padding: 2px; margin: 0; background: var(--ops-panel-3) !important; border: 1px solid var(--ops-line); border-radius: 20px !important; cursor: pointer; }
|
||||
.config-subsection .setting-switch input[type=checkbox]::before { content: ''; display: block; width: 16px; height: 16px; background: var(--ops-muted); border-radius: 50%; transition: transform .15s; }
|
||||
.config-subsection .setting-switch input[type=checkbox]:checked { background: var(--ops-primary-2) !important; border-color: var(--ops-primary-2) !important; }
|
||||
.config-subsection .setting-switch input[type=checkbox]:checked::before { background: var(--ops-primary); transform: translateX(16px); }
|
||||
.config-subsection .settings-section-actions { display: flex; justify-content: flex-end; flex-wrap: wrap; align-items: center; gap: 10px; margin: 0; padding-top: 16px; border-top: 1px solid var(--ops-line); }
|
||||
.config-subsection .settings-inline-field { padding: 0; border: 0; background: none; min-height: 0; flex: 1 1 230px; max-width: 330px; }
|
||||
.config-subsection .settings-inline-field span { font: 500 12px Inter, sans-serif; text-transform: none; }
|
||||
.config-subsection button { font: 600 12px "DM Sans", "Segoe UI", sans-serif; min-height: 38px; }
|
||||
.config-subsection .config-unsaved { margin-right: auto; font-size: 12px; color: var(--ops-warn); }
|
||||
.config-subsection .config-region-toggle { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; border: 0; background: transparent !important; padding: 0; color: var(--ops-text); text-align: left; box-shadow: none; }
|
||||
.config-region-toggle strong { font-size: 15px; }
|
||||
.config-region-toggle > span { font-size: 12px; color: var(--ops-muted); }
|
||||
.config-region-toggle + div:not([hidden]) { margin-top: 18px; }
|
||||
.config-subsection [hidden] { display: none !important; }
|
||||
.config-region-toggle + div .config-subsection-heading { display: none; }
|
||||
.config-inline-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.config-inline-controls label { display: flex; align-items: center; gap: 8px; }
|
||||
.config-inline-controls select { width: auto; }
|
||||
.admin-card .maintenance-layout { grid-template-columns: 1fr; }
|
||||
.admin-card .cache-table, .admin-card .log-viewer { overflow-x: auto; max-width: 100%; }
|
||||
.admin-card .cache-row { min-width: 650px; }
|
||||
.config-tool-link { padding: 16px 0; color: var(--ops-primary-2); font-size: 13px; }
|
||||
|
||||
@media (max-width: 1250px) {
|
||||
.admin-shell.admin-shell--with-rail { grid-template-areas: "nav main"; }
|
||||
}
|
||||
@media (max-width: 1180px) {
|
||||
.config-directory-links { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.admin-shell-nav .admin-sidebar { display: block; padding: 12px 18px; }
|
||||
.config-desktop-navigation { display: none; }
|
||||
.config-mobile-picker { display: flex; align-items: center; gap: 16px; margin: 0; }
|
||||
.config-mobile-picker > span { font: 500 12px Inter, sans-serif; color: var(--ops-muted); }
|
||||
.config-mobile-picker select { flex: 1; width: 100%; min-width: 0; padding: 10px; font: 13px Inter, sans-serif; }
|
||||
}
|
||||
@media (max-width: 680px) {
|
||||
.admin-form .admin-grid { grid-template-columns: 1fr; }
|
||||
.admin-form .config-subsection { padding: 16px !important; }
|
||||
.config-directory-link { flex-wrap: wrap; gap: 12px; padding: 14px; }
|
||||
.config-link-copy { flex-basis: calc(100% - 62px); }
|
||||
.config-directory-link .config-connection-badge { margin-left: 46px; }
|
||||
.config-link-arrow { display: none; }
|
||||
.config-advanced-directory > summary > span { display: block; margin: 8px 0 0; }
|
||||
.admin-card .admin-header { align-items: flex-start; gap: 14px; flex-direction: column; }
|
||||
.config-subsection .section-header { align-items: flex-start; gap: 12px; flex-wrap: wrap; }
|
||||
.config-subsection .section-subtitle { margin-top: 0; }
|
||||
.config-subsection .settings-section-actions > button { flex-grow: 1; }
|
||||
.config-subsection .settings-section-actions .config-unsaved { flex-basis: 100%; }
|
||||
.config-service-status { gap: 10px; }
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
type ConfigLink = { href: string; label: string; description: string; symbol?: string; service?: string }
|
||||
type ConfigGroup = { title: string; description: string; advanced?: boolean; items: ConfigLink[] }
|
||||
|
||||
export const CONFIG_GROUPS: ConfigGroup[] = [
|
||||
{ title: 'Media services', description: 'Connect the services that collect, repair and play your content.', items: [
|
||||
{ href: '/admin/seerr', label: 'Seerr', description: 'Requests and approvals', symbol: 'SE', service: 'Seerr' },
|
||||
{ href: '/admin/jellyfin', label: 'Jellyfin', description: 'Playback and library availability', symbol: 'JF', service: 'Jellyfin' },
|
||||
{ href: '/admin/sonarr', label: 'Sonarr', description: 'TV collection and quality', symbol: 'SO', service: 'Sonarr' },
|
||||
{ href: '/admin/radarr', label: 'Radarr', description: 'Movie collection and quality', symbol: 'RA', service: 'Radarr' },
|
||||
{ href: '/admin/bazarr', label: 'Bazarr', description: 'Subtitle repairs', symbol: 'BA', service: 'Bazarr' },
|
||||
{ href: '/admin/prowlarr', label: 'Prowlarr', description: 'Search sources', symbol: 'PR', service: 'Prowlarr' },
|
||||
{ href: '/admin/qbittorrent', label: 'qBittorrent', description: 'Download progress and recovery', symbol: 'QB', service: 'qBittorrent' },
|
||||
]},
|
||||
{ title: 'Preferences & access', description: 'Set the experience for your users and how issues are followed up.', items: [
|
||||
{ href: '/admin/site', label: 'Site & sign-in', description: 'Announcements and login options', symbol: '01' },
|
||||
{ href: '/admin/notifications', label: 'Email & notifications', description: 'Invites, password resets and repair updates', symbol: '02' },
|
||||
{ href: '/admin/issue-workflow', label: 'Issue follow-up', description: 'Confirmation emails and automatic closure', symbol: '03' },
|
||||
{ href: '/admin/requests', label: 'Request updates', description: 'Refresh schedule and history retention', symbol: '04' },
|
||||
{ href: '/users', label: 'Users', description: 'Accounts, email addresses and permissions', symbol: '05' },
|
||||
{ href: '/admin/invites', label: 'Invite policy & access', description: 'Defaults, profiles and issued invites', symbol: '06' },
|
||||
]},
|
||||
{ title: 'Advanced tools', description: 'Hosting and troubleshooting.', advanced: true, items: [
|
||||
{ href: '/admin/general', label: 'Hosting & proxy', description: 'Public addresses and deployment options' },
|
||||
{ href: '/admin/diagnostics', label: 'System health', description: 'Service checks and diagnostics' },
|
||||
{ href: '/admin/logs', label: 'Logs', description: 'Recent activity and log settings' },
|
||||
{ href: '/admin/cache', label: 'Request cache', description: 'Inspect saved request records' },
|
||||
{ href: '/admin/artwork', label: 'Artwork cache', description: 'Poster storage and missing artwork' },
|
||||
{ href: '/admin/maintenance', label: 'Recovery & cleanup', description: 'Database repair and history cleanup' },
|
||||
]},
|
||||
]
|
||||
|
||||
export const serviceStatusLabel = (status?: string) => ({
|
||||
up: 'Connected', down: 'Unavailable', degraded: 'Needs attention', not_configured: 'Not set up',
|
||||
}[status ?? ''] ?? 'Not checked')
|
||||
@@ -7,19 +7,7 @@ export default function AdminDiagnosticsPage() {
|
||||
return (
|
||||
<AdminShell
|
||||
title="Diagnostics"
|
||||
subtitle="Run connectivity, delivery, and platform health checks for every configured dependency."
|
||||
rail={
|
||||
<div className="admin-rail-stack">
|
||||
<div className="admin-rail-card">
|
||||
<span className="admin-rail-eyebrow">Diagnostics</span>
|
||||
<h2>Shared console</h2>
|
||||
<p>
|
||||
This page and Maintenance now use the same diagnostics panel, so every test target and
|
||||
notification ping stays in one source of truth.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
subtitle="Check connections and investigate service problems."
|
||||
>
|
||||
<AdminDiagnosticsPanel />
|
||||
</AdminShell>
|
||||
|
||||
+53
-323
@@ -1,347 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||
import { authFetch, getApiBase, getToken } from '../lib/auth'
|
||||
import AdminShell from '../ui/AdminShell'
|
||||
import { CONFIG_GROUPS, serviceStatusLabel } from './configNavigation'
|
||||
|
||||
type ServiceState = {
|
||||
name: string
|
||||
status: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
type RecentRequest = {
|
||||
id: number
|
||||
title?: string | null
|
||||
year?: number | null
|
||||
statusLabel?: string | null
|
||||
requestedBy?: string | null
|
||||
createdAt?: string | null
|
||||
}
|
||||
|
||||
type PortalOverview = {
|
||||
overview?: {
|
||||
total_items?: number
|
||||
total_comments?: number
|
||||
by_kind?: Record<string, number>
|
||||
by_status?: Record<string, number>
|
||||
}
|
||||
my_items?: number
|
||||
}
|
||||
|
||||
const formatDateTime = (value?: string | null) => {
|
||||
if (!value) return 'Unknown'
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.valueOf())) return value
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
const normalizeRecent = (items: any[]): RecentRequest[] =>
|
||||
items
|
||||
.filter((item) => item?.id)
|
||||
.map((item) => ({
|
||||
id: Number(item.id),
|
||||
title: item.title ?? null,
|
||||
year: item.year ?? null,
|
||||
statusLabel: item.statusLabel ?? null,
|
||||
requestedBy: item.requestedBy ?? null,
|
||||
createdAt: item.createdAt ?? null,
|
||||
}))
|
||||
type ServiceState = { name: string; status: string }
|
||||
|
||||
export default function AdminLandingPage() {
|
||||
const router = useRouter()
|
||||
const [services, setServices] = useState<ServiceState[]>([])
|
||||
const [serviceOverall, setServiceOverall] = useState('unknown')
|
||||
const [recent, setRecent] = useState<RecentRequest[]>([])
|
||||
const [portalOverview, setPortalOverview] = useState<PortalOverview | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
|
||||
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string>>({})
|
||||
const [serviceCheckedAt, setServiceCheckedAt] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
if (!getToken()) { router.replace('/login'); return }
|
||||
try {
|
||||
const baseUrl = getApiBase()
|
||||
const [meResponse, serviceResponse, recentResponse, overviewResponse] = await Promise.all([
|
||||
authFetch(`${baseUrl}/auth/me`),
|
||||
authFetch(`${baseUrl}/status/services`),
|
||||
authFetch(`${baseUrl}/requests/recent?take=8&days=0`),
|
||||
authFetch(`${baseUrl}/portal/overview`),
|
||||
])
|
||||
|
||||
if (meResponse.status === 401) {
|
||||
clearToken()
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
if (meResponse.status === 403) {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
const me = await meResponse.json()
|
||||
if (me?.role !== 'admin') {
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
|
||||
if (serviceResponse.ok) {
|
||||
const data = await serviceResponse.json()
|
||||
setServiceOverall(data?.overall ?? 'unknown')
|
||||
setServices(Array.isArray(data?.services) ? data.services : [])
|
||||
setServiceCheckedAt(new Date().toISOString())
|
||||
}
|
||||
|
||||
if (recentResponse.ok) {
|
||||
const data = await recentResponse.json()
|
||||
setRecent(Array.isArray(data?.results) ? normalizeRecent(data.results) : [])
|
||||
}
|
||||
|
||||
if (overviewResponse.ok) {
|
||||
const data = await overviewResponse.json()
|
||||
setPortalOverview(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError('Unable to load the operations dashboard.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
const response = await authFetch(`${getApiBase()}/auth/me`)
|
||||
if (!response.ok) { router.replace('/login'); return }
|
||||
if ((await response.json())?.role !== 'admin') { router.replace('/'); return }
|
||||
if (!active) return
|
||||
setReady(true)
|
||||
const status = await authFetch(`${getApiBase()}/status/services`)
|
||||
if (!status.ok) throw new Error('Status unavailable')
|
||||
const data = await status.json()
|
||||
if (active) setServices(Array.isArray(data.services) ? data.services : [])
|
||||
} catch {
|
||||
if (active) setError('Connection status is unavailable. Refresh the page to try again.')
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
const refreshTimer = window.setInterval(async () => {
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/status/services`)
|
||||
if (!response.ok) return
|
||||
const data = await response.json()
|
||||
setServiceOverall(data?.overall ?? 'unknown')
|
||||
setServices(Array.isArray(data?.services) ? data.services : [])
|
||||
setServiceCheckedAt(new Date().toISOString())
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
return () => window.clearInterval(refreshTimer)
|
||||
return () => { active = false }
|
||||
}, [router])
|
||||
|
||||
const testService = async (service: ServiceState) => {
|
||||
const slug = service.name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
setServiceTesting((current) => ({ ...current, [service.name]: true }))
|
||||
setServiceTestResults((current) => {
|
||||
const next = { ...current }
|
||||
delete next[service.name]
|
||||
return next
|
||||
})
|
||||
try {
|
||||
const response = await authFetch(`${getApiBase()}/status/services/${slug}/test`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
throw new Error(text || `Service test failed: ${response.status}`)
|
||||
}
|
||||
const result = await response.json()
|
||||
setServices((current) => current.map((item) =>
|
||||
item.name === service.name
|
||||
? { ...item, status: result?.status ?? item.status, message: result?.message ?? item.message }
|
||||
: item
|
||||
))
|
||||
setServiceTestResults((current) => ({
|
||||
...current,
|
||||
[service.name]: result?.message || (result?.status === 'up' ? 'Connection test passed.' : 'Connection test completed.'),
|
||||
}))
|
||||
setServiceCheckedAt(new Date().toISOString())
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setServiceTestResults((current) => ({ ...current, [service.name]: 'Connection test failed.' }))
|
||||
} finally {
|
||||
setServiceTesting((current) => ({ ...current, [service.name]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
const serviceCounts = useMemo(() => {
|
||||
const up = services.filter((service) => service.status === 'up').length
|
||||
const down = services.filter((service) => service.status === 'down').length
|
||||
const degraded = services.filter((service) => service.status === 'degraded').length
|
||||
const notConfigured = services.filter((service) => service.status === 'not_configured').length
|
||||
return { up, down, degraded, notConfigured, total: services.length }
|
||||
}, [services])
|
||||
|
||||
const issueCount = Number(portalOverview?.overview?.by_kind?.issue ?? 0)
|
||||
const requestItemCount = Number(portalOverview?.overview?.by_kind?.request ?? 0)
|
||||
const commentCount = Number(portalOverview?.overview?.total_comments ?? 0)
|
||||
|
||||
const rail = (
|
||||
<div className="admin-rail-stack">
|
||||
<div className="admin-rail-card">
|
||||
<span className="admin-rail-eyebrow">Fleet summary</span>
|
||||
<h2>{serviceCounts.up} of {serviceCounts.total || 0} online</h2>
|
||||
<p>
|
||||
{serviceCounts.down + serviceCounts.degraded > 0
|
||||
? `${serviceCounts.down + serviceCounts.degraded} configured service${serviceCounts.down + serviceCounts.degraded === 1 ? '' : 's'} need attention.`
|
||||
: 'No configured service is currently reporting a fault.'}
|
||||
</p>
|
||||
<a className="admin-rail-action" href="/admin/diagnostics">Open full diagnostics</a>
|
||||
</div>
|
||||
<div className="admin-rail-card">
|
||||
<span className="admin-rail-eyebrow">Quick actions</span>
|
||||
<div className="quick-action-grid">
|
||||
<a href="/admin/requests-all">Review requests</a>
|
||||
<a href="/admin/issues">Manage issues</a>
|
||||
<a href="/users">User directory</a>
|
||||
<a href="/admin/logs">Activity log</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<AdminShell
|
||||
title="Admin overview"
|
||||
subtitle="Service health, request movement, issue intake, and the controls that keep Magent running."
|
||||
rail={rail}
|
||||
actions={
|
||||
<button type="button" onClick={() => router.push('/admin/diagnostics')}>
|
||||
Run diagnostics
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{loading ? <div className="status-banner">Loading operations dashboard...</div> : null}
|
||||
{error ? <div className="error-banner">{error}</div> : null}
|
||||
|
||||
<section className="ops-metric-grid">
|
||||
<div className="ops-metric-card">
|
||||
<span className="section-kicker">Services online</span>
|
||||
<strong>
|
||||
{serviceCounts.up}/{serviceCounts.total || 0}
|
||||
</strong>
|
||||
<p>{serviceOverall === 'up' ? 'All configured services are responding.' : 'Some services need review.'}</p>
|
||||
</div>
|
||||
<div className="ops-metric-card">
|
||||
<span className="section-kicker">Recent requests</span>
|
||||
<strong>{recent.length}</strong>
|
||||
<p>Loaded from the live request cache.</p>
|
||||
</div>
|
||||
<div className="ops-metric-card">
|
||||
<span className="section-kicker">Open issue items</span>
|
||||
<strong>{issueCount}</strong>
|
||||
<p>{commentCount} portal comments recorded.</p>
|
||||
</div>
|
||||
<div className="ops-metric-card">
|
||||
<span className="section-kicker">Portal requests</span>
|
||||
<strong>{requestItemCount}</strong>
|
||||
<p>Tracked in the dedicated request workflow.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-zone fleet-status-panel">
|
||||
<div className="section-header fleet-status-header">
|
||||
<div>
|
||||
<span className="section-kicker">Fleet service mesh</span>
|
||||
<h2>System status</h2>
|
||||
<p className="section-subtitle">
|
||||
Admin-only connectivity status for the services used by Magent.
|
||||
{serviceCheckedAt ? ` Last checked ${formatDateTime(serviceCheckedAt)}.` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`small-pill system-pill-${serviceOverall}`}>
|
||||
{serviceOverall.replaceAll('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
{services.length === 0 ? (
|
||||
<div className="status-banner">Service status is not available yet.</div>
|
||||
) : (
|
||||
<div className="fleet-service-grid">
|
||||
{services.map((service) => {
|
||||
const slug = service.name.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
const testing = Boolean(serviceTesting[service.name])
|
||||
return (
|
||||
<article className={`fleet-service-card system-${service.status}`} key={service.name}>
|
||||
<div className="fleet-service-title">
|
||||
<span className="system-dot" aria-hidden="true" />
|
||||
<div>
|
||||
<h3>{service.name}</h3>
|
||||
<span className={`small-pill system-pill-${service.status}`}>
|
||||
{service.status.replaceAll('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p>{serviceTestResults[service.name] ?? service.message ?? 'No recent detail was returned.'}</p>
|
||||
<div className="fleet-service-actions">
|
||||
<a href={`/admin/${slug}`}>Configure</a>
|
||||
<button type="button" className="ghost-button" disabled={testing} onClick={() => void testService(service)}>
|
||||
{testing ? 'Testing...' : 'Test connection'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-zone">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Recent activity</h2>
|
||||
<p className="section-subtitle">Live request cache entries, newest first.</p>
|
||||
</div>
|
||||
</div>
|
||||
{recent.length === 0 ? (
|
||||
<div className="status-banner">No recent requests were returned.</div>
|
||||
) : (
|
||||
<div className="admin-table dashboard-activity-table">
|
||||
<div className="admin-table-head">
|
||||
<span>Request</span>
|
||||
<span>Status</span>
|
||||
<span>User</span>
|
||||
<span>Created</span>
|
||||
<AdminShell title="Settings" subtitle="Connections, access and the way Magent works.">
|
||||
{!ready ? error ? <p className="error-banner" role="alert">{error}</p> : <p role="status">Loading settings…</p> : (
|
||||
<div className="config-directory">
|
||||
{error && <p className="error-banner" role="alert">{error}</p>}
|
||||
{CONFIG_GROUPS.filter((group) => !group.advanced).map((group) => (
|
||||
<section className="config-directory-region" key={group.title}>
|
||||
<header><h2>{group.title}</h2><p>{group.description}</p></header>
|
||||
<div className="config-directory-links">
|
||||
{group.items.map((item) => {
|
||||
const service = services.find((entry) => entry.name.toLowerCase() === item.service?.toLowerCase())
|
||||
return (
|
||||
<a href={item.href} key={item.href} className="config-directory-link">
|
||||
<span className="config-link-icon" aria-hidden="true">{item.symbol}</span>
|
||||
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
|
||||
{item.service && <span className={`config-connection-badge is-${service?.status ?? 'unknown'}`}>{serviceStatusLabel(service?.status)}</span>}
|
||||
<span className="config-link-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
<details className="config-advanced-directory">
|
||||
<summary><strong>Advanced tools</strong><span>Hosting, logs, caches and recovery</span></summary>
|
||||
<div className="config-directory-links">
|
||||
{CONFIG_GROUPS.filter((group) => group.advanced).flatMap((group) => group.items).map((item) => (
|
||||
<a href={item.href} key={item.href} className="config-directory-link">
|
||||
<span className="config-link-copy"><strong>{item.label}</strong><small>{item.description}</small></span>
|
||||
<span className="config-link-arrow" aria-hidden="true">→</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
{recent.map((row) => (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
className="admin-table-row"
|
||||
onClick={() => router.push(`/requests/${row.id}`)}
|
||||
>
|
||||
<span>
|
||||
{row.title || `Request #${row.id}`}
|
||||
{row.year ? ` (${row.year})` : ''}
|
||||
</span>
|
||||
<span>{row.statusLabel || 'Unknown'}</span>
|
||||
<span>{row.requestedBy || 'Unknown'}</span>
|
||||
<span>{formatDateTime(row.createdAt)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-zone">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Attention states</h2>
|
||||
<p className="section-subtitle">Service states that affect request processing.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div className="ops-status-strip">
|
||||
<span>{serviceCounts.down} down</span>
|
||||
<span>{serviceCounts.degraded} degraded</span>
|
||||
<span>{serviceCounts.notConfigured} not configured</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</AdminShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6435,7 +6435,8 @@ textarea {
|
||||
}
|
||||
}
|
||||
|
||||
/* Final header account menu stacking override (must be last) */
|
||||
/* Keep the account menu above header controls. Header positioning is owned
|
||||
by the responsive application shell in ops-redesign.css. */
|
||||
.page,
|
||||
.header,
|
||||
.header-left,
|
||||
@@ -6447,9 +6448,7 @@ textarea {
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative !important;
|
||||
isolation: isolate;
|
||||
z-index: 20 !important;
|
||||
}
|
||||
|
||||
.header-nav,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import './globals.css'
|
||||
import './ops-redesign.css'
|
||||
import './admin/config.css'
|
||||
import type { ReactNode } from 'react'
|
||||
import BrandingFavicon from './ui/BrandingFavicon'
|
||||
import BrandingLogo from './ui/BrandingLogo'
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
|
||||
<main className="card admin-card">
|
||||
<div className="admin-header">
|
||||
<div>
|
||||
<span className="section-kicker">Beta stream</span>
|
||||
<span className="section-kicker">Configuration</span>
|
||||
<h1>{title}</h1>
|
||||
{subtitle && <p className="lede">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,85 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
const NAV_GROUPS = [
|
||||
{
|
||||
title: 'Configuration',
|
||||
items: [
|
||||
{ href: '/admin', label: 'Config overview' },
|
||||
{ href: '/admin/general', label: 'Application & proxy' },
|
||||
{ href: '/admin/site', label: 'Site & login' },
|
||||
{ href: '/admin/notifications', label: 'Notifications' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Media Services',
|
||||
items: [
|
||||
{ href: '/admin/seerr', label: 'Seerr' },
|
||||
{ href: '/admin/jellyfin', label: 'Jellyfin' },
|
||||
{ href: '/admin/sonarr', label: 'Sonarr' },
|
||||
{ href: '/admin/radarr', label: 'Radarr' },
|
||||
{ href: '/admin/bazarr', label: 'Bazarr' },
|
||||
{ href: '/admin/prowlarr', label: 'Prowlarr' },
|
||||
{ href: '/admin/qbittorrent', label: 'qBittorrent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Request Pipeline',
|
||||
items: [
|
||||
{ href: '/admin/requests', label: 'Sync & retention' },
|
||||
{ href: '/admin/issue-workflow', label: 'Issue workflow' },
|
||||
{ href: '/admin/cache', label: 'Request cache' },
|
||||
{ href: '/admin/artwork', label: 'Artwork cache' },
|
||||
{ href: '/admin/requests-all', label: 'All requests' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Users & Access',
|
||||
items: [
|
||||
{ href: '/users', label: 'Users' },
|
||||
{ href: '/admin/invites', label: 'Invite management' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
items: [
|
||||
{ href: '/admin/diagnostics', label: 'System health' },
|
||||
{ href: '/admin/logs', label: 'Activity log' },
|
||||
{ href: '/admin/maintenance', label: 'Maintenance' },
|
||||
{ href: '/admin/system', label: 'How it works' },
|
||||
],
|
||||
},
|
||||
]
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { CONFIG_GROUPS } from '../admin/configNavigation'
|
||||
|
||||
export default function AdminSidebar() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const links = CONFIG_GROUPS.flatMap((group) => group.items)
|
||||
const current = links.find((item) => item.href === pathname)
|
||||
const renderLinks = (items: typeof links) => (
|
||||
<div className="admin-nav-links">
|
||||
{items.map((item) => <a key={item.href} href={item.href} aria-current={pathname === item.href ? 'page' : undefined} className={pathname === item.href ? 'is-active' : ''}>{item.label}</a>)}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<nav className="admin-sidebar">
|
||||
<div className="admin-sidebar-identity">
|
||||
<strong>Magent Admin</strong>
|
||||
<span><i aria-hidden="true" />Configuration workspace</span>
|
||||
<nav className="admin-sidebar" aria-label="Settings navigation">
|
||||
<label className="config-mobile-picker">
|
||||
<span>Settings</span>
|
||||
<select aria-label="Settings section" value={current?.href ?? '/admin'} onChange={(event) => router.push(event.target.value)}>
|
||||
<option value="/admin">Settings overview</option>
|
||||
{CONFIG_GROUPS.map((group) => <optgroup label={group.title} key={group.title}>{group.items.map((item) => <option key={item.href} value={item.href}>{item.label}</option>)}</optgroup>)}
|
||||
</select>
|
||||
</label>
|
||||
<div className="config-desktop-navigation">
|
||||
<a href="/admin" className="config-sidebar-home" aria-current={pathname === '/admin' ? 'page' : undefined}>Settings <span aria-hidden="true">↗</span></a>
|
||||
{CONFIG_GROUPS.map((group) => group.advanced ? (
|
||||
<details className="admin-nav-group config-nav-advanced" key={pathname + group.title} open={group.items.some((item) => item.href === pathname) || undefined}>
|
||||
<summary>{group.title}</summary>{renderLinks(group.items)}
|
||||
</details>
|
||||
) : (
|
||||
<div className="admin-nav-group" key={group.title}><span className="admin-nav-title">{group.title}</span>{renderLinks(group.items)}</div>
|
||||
))}
|
||||
<a href="/" className="config-sidebar-back">← My requests</a>
|
||||
</div>
|
||||
<a className="admin-new-request" href="/new-requests"><span>+</span> New request</a>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.title} className="admin-nav-group">
|
||||
<span className="admin-nav-title">{group.title}</span>
|
||||
<div className="admin-nav-links">
|
||||
{group.items.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== '/' &&
|
||||
item.href !== '/admin' &&
|
||||
pathname.startsWith(`${item.href}/`))
|
||||
return (
|
||||
<a key={item.href} href={item.href} className={isActive ? 'is-active' : ''}>
|
||||
{item.label}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function WorkspaceNavigation() {
|
||||
const pathname = usePathname()
|
||||
const [role, setRole] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
const [showRequestsNav, setShowRequestsNav] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken()
|
||||
@@ -46,28 +47,32 @@ export default function WorkspaceNavigation() {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
authFetch(`${getApiBase()}/auth/me`)
|
||||
.then(async (response) => {
|
||||
Promise.all([
|
||||
authFetch(`${getApiBase()}/auth/me`),
|
||||
fetch(`${getApiBase()}/site/public`).catch(() => null),
|
||||
])
|
||||
.then(async ([response, siteResponse]) => {
|
||||
if (response.ok) setRole((await response.json())?.role ?? 'user')
|
||||
if (siteResponse?.ok) setShowRequestsNav((await siteResponse.json())?.navigation?.showRequests !== false)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => setReady(true))
|
||||
}, [])
|
||||
|
||||
if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route)) || pathname.startsWith('/admin')) {
|
||||
if (!ready || !getToken() || HIDDEN_ROUTES.some((route) => pathname.startsWith(route))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const items = NAVIGATION.filter((item) => !item.adminOnly || role === 'admin')
|
||||
const items = NAVIGATION.filter((item) => (!item.adminOnly || role === 'admin') && (showRequestsNav || item.href !== '/new-requests'))
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="workspace-sidebar" aria-label="Workspace navigation">
|
||||
{!pathname.startsWith('/admin') && <aside className="workspace-sidebar" aria-label="Workspace navigation">
|
||||
<div className="workspace-sidebar-identity">
|
||||
<BrandingLogo className="workspace-sidebar-logo" />
|
||||
<div><strong>{role === 'admin' ? 'Magent Admin' : 'Magent'}</strong><span>Media operations workspace</span></div>
|
||||
</div>
|
||||
<a className="workspace-new-request" href="/new-requests"><span>+</span> New request</a>
|
||||
{showRequestsNav && <a className="workspace-new-request" href="/new-requests"><span>+</span> New request</a>}
|
||||
<nav>
|
||||
{items.map((item) => (
|
||||
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
|
||||
@@ -79,7 +84,7 @@ export default function WorkspaceNavigation() {
|
||||
<a href="/feedback">Support</a>
|
||||
<a href={role === 'admin' ? '/admin' : '/profile'}>Settings</a>
|
||||
</div>
|
||||
</aside>
|
||||
</aside>}
|
||||
<nav className="workspace-mobile-nav" aria-label="Mobile navigation">
|
||||
{items.slice(0, 5).map((item) => (
|
||||
<a key={item.href} href={item.href} className={item.match(pathname) ? 'is-active' : undefined}>
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
// Run with Node and Playwright. REVIEW_DIR/session.json holds an authorised
|
||||
// short-lived session as { name, token }. Never commit this file. Live requests are read-only:
|
||||
// all writes are blocked, with save/validation paths checked using fixtures.
|
||||
const reviewDir = process.env.REVIEW_DIR || '/review'
|
||||
const { chromium } = require(process.env.REVIEW_PLAYWRIGHT || reviewDir + '/node_modules/playwright')
|
||||
const fs = require('node:fs')
|
||||
const assert = require('node:assert/strict')
|
||||
const base = process.env.REVIEW_BASE || 'https://beta.grizzlyflix.co.nz'
|
||||
const liveBase = process.env.REVIEW_LIVE_BASE || 'https://beta.grizzlyflix.co.nz'
|
||||
const prefix = process.env.REVIEW_PREFIX || 'before'
|
||||
const session = JSON.parse(fs.readFileSync(reviewDir + '/session.json', 'utf8'))
|
||||
|
||||
;(async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'] })
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } })
|
||||
await context.addCookies([
|
||||
{ name: session.name, value: session.token, url: base, httpOnly: true, secure: base.startsWith('https') },
|
||||
{ name: 'magent_logged_in', value: '1', url: base },
|
||||
])
|
||||
const errors = []
|
||||
const httpErrors = []
|
||||
const blocked = []
|
||||
await context.route('**/api/**', async (route) => {
|
||||
const request = route.request()
|
||||
if (request.url().includes('/events/stream')) return route.fulfill({ status: 200, contentType: 'text/event-stream', body: ': review\n\n' })
|
||||
if (!['GET', 'HEAD', 'OPTIONS'].includes(request.method())) {
|
||||
blocked.push({ path: new URL(request.url()).pathname, method: request.method() })
|
||||
return route.fulfill({ status: 409, json: { detail: 'Read-only browser review' } })
|
||||
}
|
||||
if (base !== liveBase) {
|
||||
const remote = liveBase + new URL(request.url()).pathname + new URL(request.url()).search
|
||||
try {
|
||||
const response = await route.fetch({ url: remote, headers: { ...request.headers(), cookie: session.name + '=' + session.token } })
|
||||
return await route.fulfill({ response })
|
||||
} catch {
|
||||
// Browser cancellation must not print request headers (including cookies).
|
||||
await route.abort().catch(() => {})
|
||||
return
|
||||
}
|
||||
}
|
||||
await route.continue()
|
||||
})
|
||||
const page = await context.newPage()
|
||||
page.on('pageerror', (error) => errors.push(error.message))
|
||||
page.on('response', (response) => { if (response.status() >= 400) httpErrors.push({ path: new URL(response.url()).pathname, status: response.status() }) })
|
||||
const reports = []
|
||||
const paths = process.env.REVIEW_PATHS ? process.env.REVIEW_PATHS.split(',') : prefix === 'before' ? ['/admin', '/admin/radarr', '/admin/notifications', '/admin/site'] : [
|
||||
'/admin', '/admin/seerr', '/admin/jellyfin', '/admin/sonarr', '/admin/radarr', '/admin/bazarr',
|
||||
'/admin/prowlarr', '/admin/qbittorrent', '/admin/site', '/admin/notifications', '/admin/issue-workflow',
|
||||
'/admin/requests', '/admin/general', '/admin/cache', '/admin/artwork', '/admin/logs', '/admin/maintenance',
|
||||
'/admin/invites', '/admin/diagnostics', '/', '/new-requests', '/portal/issues', '/profile/invites', '/profile',
|
||||
]
|
||||
for (const path of paths) {
|
||||
await page.goto(base + path, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1600)
|
||||
if (path.startsWith('/admin/') && !['/admin/invites','/admin/diagnostics'].includes(path)) {
|
||||
await page.locator('.admin-card').waitFor({ timeout: 30000 }).catch(async (error) => {
|
||||
console.log(JSON.stringify({ failedPath: path, location: page.url(), errors, httpErrors, blocked }))
|
||||
throw error
|
||||
})
|
||||
} else await page.locator('main').first().waitFor()
|
||||
const stats = await page.evaluate(() => ({
|
||||
title: document.querySelector('main h1')?.textContent,
|
||||
width: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
visibleInputs: [...document.querySelectorAll('main input,main select,main textarea')].filter((el) => el.getBoundingClientRect().height > 0).length,
|
||||
}))
|
||||
reports.push({ path, ...stats })
|
||||
if (['/admin','/admin/radarr','/admin/notifications','/admin/site','/admin/issue-workflow'].includes(path)) {
|
||||
await page.screenshot({ path: reviewDir + '/' + prefix + path.replaceAll('/','-') + '.png', fullPage: true })
|
||||
}
|
||||
console.log(JSON.stringify({ path, ...stats }))
|
||||
}
|
||||
for (const path of ['/admin','/admin/radarr','/admin/cache','/new-requests','/portal/issues','/profile/invites']) {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await page.goto(base + path, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1800)
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
reports.push({ mobile: path, overflow })
|
||||
await page.screenshot({ path: reviewDir + '/' + prefix + '-mobile' + path.replaceAll('/','-') + '.png', fullPage: true })
|
||||
console.log(JSON.stringify({ mobile: path, overflow }))
|
||||
}
|
||||
if (prefix !== 'before') {
|
||||
await page.setViewportSize({ width: 1440, height: 1000 })
|
||||
await page.goto(base + '/admin/radarr')
|
||||
const field = page.locator('input[name=radarr_base_url]')
|
||||
await field.waitFor()
|
||||
const saved = await field.inputValue()
|
||||
const region = page.locator('#config-radarr-connection')
|
||||
assert(await region.getByRole('button', { name: 'Save changes', exact: true }).isDisabled())
|
||||
await field.fill(saved + '/unsaved-review')
|
||||
assert(await region.getByRole('button', { name: 'Test connection', exact: true }).isDisabled())
|
||||
await region.getByRole('button', { name: 'Discard', exact: true }).click()
|
||||
assert.equal(await field.inputValue(), saved)
|
||||
// Saving is intercepted: verify only this region is submitted, and
|
||||
// leaving an existing secret blank never overwrites that secret.
|
||||
let payload
|
||||
await page.route('**/api/admin/settings', async (route) => {
|
||||
if (route.request().method() !== 'PUT') return route.fallback()
|
||||
payload = route.request().postDataJSON()
|
||||
return route.fulfill({ status: 200, json: { ok: true } })
|
||||
})
|
||||
await field.fill(saved + '/review')
|
||||
await region.getByRole('button', { name: 'Save changes', exact: true }).click()
|
||||
await page.waitForTimeout(1000)
|
||||
assert(payload && payload.radarr_base_url.endsWith('/review'))
|
||||
assert(!('radarr_api_key' in payload))
|
||||
assert(!('sonarr_base_url' in payload))
|
||||
await page.goto(base + '/admin/issue-workflow')
|
||||
await page.locator('select[name=issue_confirmation_contact_attempts]').selectOption('0')
|
||||
assert(!(await page.locator('[name=issue_confirmation_interval_value]').count()))
|
||||
await page.locator('select[name=issue_confirmation_contact_attempts]').selectOption('3')
|
||||
await page.locator('input[name=issue_confirmation_interval_value]').fill('366')
|
||||
assert(!(await page.locator('input[name=issue_confirmation_interval_value]').evaluate((el) => el.checkValidity())))
|
||||
await page.goto(base + '/admin/notifications')
|
||||
const discord = page.locator('#config-magent-notify-discord')
|
||||
await discord.getByRole('button', { name: /Discord/ }).click()
|
||||
const toggle = discord.getByRole('switch')
|
||||
await toggle.check()
|
||||
assert(await discord.locator('[name=magent_notify_discord_webhook_url]').isVisible())
|
||||
await discord.getByRole('button', { name: 'Discard', exact: true }).click()
|
||||
assert(!(await discord.locator('[name=magent_notify_discord_webhook_url]').count()))
|
||||
await page.setViewportSize({ width: 1024, height: 768 })
|
||||
await page.goto(base + '/admin/cache')
|
||||
await page.getByRole('button', { name: 'Load saved requests', exact: true }).waitFor()
|
||||
await page.screenshot({ path: reviewDir + '/tablet-cache.png', fullPage: true })
|
||||
assert(await page.getByRole('button', { name: 'Load saved requests', exact: true }).isVisible())
|
||||
await page.getByRole('button', { name: 'Load saved requests', exact: true }).click()
|
||||
await page.waitForTimeout(700)
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false)
|
||||
await page.goto(base + '/portal/issues')
|
||||
await page.waitForTimeout(1800)
|
||||
const report = page.locator('.portal-item-list > button').first()
|
||||
if (await report.count()) {
|
||||
await report.click()
|
||||
const modal = page.locator('.issue-detail-modal.is-open')
|
||||
await modal.waitFor()
|
||||
const top = await modal.boundingBox()
|
||||
const header = await page.locator('.header').boundingBox()
|
||||
assert(top.y >= header.y + header.height, 'Issue window must clear the header')
|
||||
await modal.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
assert(!(await page.locator('.issue-detail-modal.is-open').count()))
|
||||
console.log('PASS: issue popup opens, clears the header and closes')
|
||||
}
|
||||
await page.goto(base + '/')
|
||||
await page.locator('.recent-card').first().waitFor({ timeout: 30000 }).catch(() => {})
|
||||
const request = page.locator('.recent-card').first()
|
||||
if (await request.count()) {
|
||||
await request.click()
|
||||
await page.waitForTimeout(2500)
|
||||
assert(new URL(page.url()).pathname.startsWith('/requests/'))
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth), false)
|
||||
console.log('PASS: request detail navigation and layout')
|
||||
}
|
||||
assert(reports.every((report) => report.overflow == null || report.overflow === 0), 'Mobile overflow')
|
||||
console.log('PASS: dirty state, discard, region-only save, secret preservation, confirmation limits')
|
||||
}
|
||||
fs.writeFileSync(reviewDir + '/' + prefix + '-report.json', JSON.stringify({ reports, errors, blocked }, null, 2))
|
||||
console.log(JSON.stringify({ errors, blocked }))
|
||||
await page.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
await context.unrouteAll({ behavior: 'ignoreErrors' })
|
||||
await browser.close()
|
||||
if (errors.length) process.exitCode = 1
|
||||
})().catch((error) => { console.error(error); process.exit(1) })
|
||||
Reference in New Issue
Block a user