Redesign beta Magent UI
This commit is contained in:
@@ -19,6 +19,12 @@ type ServiceOptions = {
|
|||||||
qualityProfiles: { id: number; name: string; label: string }[]
|
qualityProfiles: { id: number; name: string; label: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServiceStatus = {
|
||||||
|
name: string
|
||||||
|
status: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
const SECTION_LABELS: Record<string, string> = {
|
const SECTION_LABELS: Record<string, string> = {
|
||||||
magent: 'Magent',
|
magent: 'Magent',
|
||||||
general: 'General',
|
general: 'General',
|
||||||
@@ -414,6 +420,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
|
||||||
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
|
const [maintenanceBusy, setMaintenanceBusy] = useState(false)
|
||||||
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
const [liveStreamConnected, setLiveStreamConnected] = useState(false)
|
||||||
|
const [serviceStatuses, setServiceStatuses] = useState<ServiceStatus[]>([])
|
||||||
|
const [serviceStatusCheckedAt, setServiceStatusCheckedAt] = useState<string | null>(null)
|
||||||
const requestsSyncRef = useRef<any | null>(null)
|
const requestsSyncRef = useRef<any | null>(null)
|
||||||
const artworkPrefetchRef = useRef<any | null>(null)
|
const artworkPrefetchRef = useRef<any | null>(null)
|
||||||
const computeProgressPercent = (
|
const computeProgressPercent = (
|
||||||
@@ -543,6 +551,21 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const loadServiceStatuses = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/status/services`)
|
||||||
|
if (!response.ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setServiceStatuses(Array.isArray(data?.services) ? data.services : [])
|
||||||
|
setServiceStatusCheckedAt(new Date().toISOString())
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
@@ -550,7 +573,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await loadSettings()
|
await Promise.all([loadSettings(), loadServiceStatuses()])
|
||||||
if (section === 'cache' || section === 'artwork') {
|
if (section === 'cache' || section === 'artwork') {
|
||||||
await loadArtworkPrefetchStatus()
|
await loadArtworkPrefetchStatus()
|
||||||
await loadArtworkSummary()
|
await loadArtworkSummary()
|
||||||
@@ -570,7 +593,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
if (section === 'radarr') {
|
if (section === 'radarr') {
|
||||||
void loadOptions('radarr')
|
void loadOptions('radarr')
|
||||||
}
|
}
|
||||||
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section])
|
}, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadServiceStatuses, loadSettings, router, section])
|
||||||
|
|
||||||
const groupedSettings = useMemo(() => {
|
const groupedSettings = useMemo(() => {
|
||||||
const groups: Record<string, AdminSetting[]> = {}
|
const groups: Record<string, AdminSetting[]> = {}
|
||||||
@@ -583,6 +606,22 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}, [settings])
|
}, [settings])
|
||||||
|
|
||||||
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
|
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
|
||||||
|
const statusNamesBySection: Record<string, string[]> = {
|
||||||
|
seerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
|
||||||
|
jellyseerr: ['Seerr', 'Jellyseerr', 'Jellyseer'],
|
||||||
|
jellyfin: ['Jellyfin'],
|
||||||
|
sonarr: ['Sonarr'],
|
||||||
|
radarr: ['Radarr'],
|
||||||
|
prowlarr: ['Prowlarr'],
|
||||||
|
qbittorrent: ['qBittorrent', 'Qbittorrent'],
|
||||||
|
}
|
||||||
|
const statusNames = statusNamesBySection[section] ?? statusNamesBySection[settingsSection ?? ''] ?? []
|
||||||
|
const currentServiceStatus = serviceStatuses.find((service) =>
|
||||||
|
statusNames.some((name) => name.toLowerCase() === service.name.toLowerCase())
|
||||||
|
)
|
||||||
|
const currentServiceConfigured = currentServiceStatus
|
||||||
|
? currentServiceStatus.status !== 'not_configured'
|
||||||
|
: null
|
||||||
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
const isMagentGroupedSection = section === 'magent' || section === 'general' || section === 'notifications'
|
||||||
const isSiteGroupedSection = section === 'site'
|
const isSiteGroupedSection = section === 'site'
|
||||||
const visibleSections = settingsSection ? [settingsSection] : []
|
const visibleSections = settingsSection ? [settingsSection] : []
|
||||||
@@ -1681,6 +1720,39 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{status && <div className="error-banner">{status}</div>}
|
{status && <div className="error-banner">{status}</div>}
|
||||||
|
{currentServiceStatus ? (
|
||||||
|
<section className="admin-section admin-zone service-status-panel">
|
||||||
|
<div className="service-status-summary">
|
||||||
|
<span className={`system-dot system-dot-${currentServiceStatus.status}`} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Connection status</span>
|
||||||
|
<h2>{currentServiceStatus.name}</h2>
|
||||||
|
<p className="section-subtitle">
|
||||||
|
{currentServiceStatus.message ?? 'No service message was returned.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="service-status-grid">
|
||||||
|
<div>
|
||||||
|
<span>Status</span>
|
||||||
|
<strong>{currentServiceStatus.status.replaceAll('_', ' ')}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Configuration</span>
|
||||||
|
<strong>{currentServiceConfigured ? 'Configured' : 'Not configured'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Last checked</span>
|
||||||
|
<strong>
|
||||||
|
{serviceStatusCheckedAt ? new Date(serviceStatusCheckedAt).toLocaleString() : 'Not checked yet'}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="ghost-button" onClick={() => void loadServiceStatuses()}>
|
||||||
|
Refresh status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
{settingsSections.length > 0 ? (
|
{settingsSections.length > 0 ? (
|
||||||
<div className="admin-form admin-zone-stack">
|
<div className="admin-form admin-zone-stack">
|
||||||
{settingsSections
|
{settingsSections
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import PortalClient from '../../portal/PortalClient'
|
||||||
|
|
||||||
|
export default function AdminIssuesPage() {
|
||||||
|
return <PortalClient workspace="issue" />
|
||||||
|
}
|
||||||
+240
-6
@@ -1,24 +1,258 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
import AdminShell from '../ui/AdminShell'
|
import AdminShell from '../ui/AdminShell'
|
||||||
|
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
|
||||||
export default function AdminLandingPage() {
|
export default function AdminLandingPage() {
|
||||||
const router = useRouter()
|
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)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getToken()) {
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
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 : [])
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
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">Service ecosystem</span>
|
||||||
|
<div className="service-ecosystem">
|
||||||
|
{services.length === 0 ? (
|
||||||
|
<div className="status-banner">Service status is not available yet.</div>
|
||||||
|
) : (
|
||||||
|
services.map((service) => (
|
||||||
|
<a
|
||||||
|
key={service.name}
|
||||||
|
className="service-row"
|
||||||
|
href={`/admin/${service.name.toLowerCase().replace(/[^a-z0-9]/g, '')}`}
|
||||||
|
>
|
||||||
|
<span className={`system-dot system-dot-${service.status}`} />
|
||||||
|
<span>
|
||||||
|
<strong>{service.name}</strong>
|
||||||
|
<small>{service.message ?? 'No message reported'}</small>
|
||||||
|
</span>
|
||||||
|
<span className={`small-pill system-pill-${service.status}`}>{service.status}</span>
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</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 (
|
return (
|
||||||
<AdminShell
|
<AdminShell
|
||||||
title="Settings"
|
title="Operations Center"
|
||||||
subtitle="Choose what you want to manage."
|
subtitle="Live Magent controls, request movement, issue intake, and service health."
|
||||||
|
rail={rail}
|
||||||
actions={
|
actions={
|
||||||
<button type="button" onClick={() => router.push('/')}>
|
<button type="button" onClick={() => router.push('/')}>
|
||||||
Back to requests
|
View health
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<section className="admin-section">
|
{loading ? <div className="status-banner">Loading operations dashboard...</div> : null}
|
||||||
<div className="status-banner">
|
{error ? <div className="error-banner">{error}</div> : null}
|
||||||
Pick a section from the left. Each page explains what it does and how it helps.
|
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<div className="ops-status-strip">
|
||||||
|
<span>{serviceCounts.down} down</span>
|
||||||
|
<span>{serviceCounts.degraded} degraded</span>
|
||||||
|
<span>{serviceCounts.notConfigured} not configured</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</AdminShell>
|
</AdminShell>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import './globals.css'
|
import './globals.css'
|
||||||
|
import './ops-redesign.css'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import HeaderActions from './ui/HeaderActions'
|
import HeaderActions from './ui/HeaderActions'
|
||||||
import HeaderIdentity from './ui/HeaderIdentity'
|
import HeaderIdentity from './ui/HeaderIdentity'
|
||||||
import ThemeToggle from './ui/ThemeToggle'
|
|
||||||
import BrandingFavicon from './ui/BrandingFavicon'
|
import BrandingFavicon from './ui/BrandingFavicon'
|
||||||
import BrandingLogo from './ui/BrandingLogo'
|
import BrandingLogo from './ui/BrandingLogo'
|
||||||
import SiteStatus from './ui/SiteStatus'
|
import SiteStatus from './ui/SiteStatus'
|
||||||
@@ -24,12 +24,12 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||||||
<BrandingLogo className="brand-logo brand-logo--header" />
|
<BrandingLogo className="brand-logo brand-logo--header" />
|
||||||
<div className="brand-stack">
|
<div className="brand-stack">
|
||||||
<div className="brand">Magent</div>
|
<div className="brand">Magent</div>
|
||||||
<div className="tagline">Find and fix media requests fast.</div>
|
<div className="tagline">GrizzlyFlix media operations</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div className="header-right">
|
<div className="header-right">
|
||||||
<ThemeToggle />
|
<span className="beta-chip" aria-label="Beta environment">Beta</span>
|
||||||
<HeaderIdentity />
|
<HeaderIdentity />
|
||||||
</div>
|
</div>
|
||||||
<div className="header-nav">
|
<div className="header-nav">
|
||||||
|
|||||||
@@ -108,10 +108,17 @@ export default function LoginPage() {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card auth-card">
|
<main className="auth-screen">
|
||||||
|
<section className="auth-hero">
|
||||||
|
<div className="auth-mark">
|
||||||
<BrandingLogo className="brand-logo brand-logo--login" />
|
<BrandingLogo className="brand-logo brand-logo--login" />
|
||||||
<h1>Sign in</h1>
|
</div>
|
||||||
<p className="lede">{loginHelpText}</p>
|
<div className="auth-title-block">
|
||||||
|
<span className="section-kicker">Secure access</span>
|
||||||
|
<h1>Magent operational gateway</h1>
|
||||||
|
<p>{loginHelpText}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<form
|
<form
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
if (!primaryMode) {
|
if (!primaryMode) {
|
||||||
@@ -121,23 +128,25 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
void submit(event, primaryMode)
|
void submit(event, primaryMode)
|
||||||
}}
|
}}
|
||||||
className="auth-form"
|
className="auth-form auth-panel"
|
||||||
>
|
>
|
||||||
<label>
|
<label>
|
||||||
Username
|
<span>Username</span>
|
||||||
<input
|
<input
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(event) => setUsername(event.target.value)}
|
onChange={(event) => setUsername(event.target.value)}
|
||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
|
placeholder="Enter your username"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Password
|
<span>Password</span>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(event) => setPassword(event.target.value)}
|
onChange={(event) => setPassword(event.target.value)}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
|
placeholder="Enter your password"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
{error && <div className="error-banner">{error}</div>}
|
{error && <div className="error-banner">{error}</div>}
|
||||||
@@ -171,6 +180,10 @@ export default function LoginPage() {
|
|||||||
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
{!loginOptions.showJellyfinLogin && !loginOptions.showLocalLogin ? (
|
||||||
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
<div className="error-banner">Login is currently disabled. Contact an administrator.</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="auth-footnote">
|
||||||
|
<span className="live-dot" aria-hidden="true" />
|
||||||
|
Beta environment
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -362,8 +362,42 @@ export default function HomePage() {
|
|||||||
return date.toLocaleString()
|
return date.toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const serviceItems = servicesStatus?.services ?? []
|
||||||
|
const serviceUpCount = serviceItems.filter((service) => service.status === 'up').length
|
||||||
|
const serviceAttentionCount = serviceItems.filter((service) =>
|
||||||
|
['down', 'degraded', 'not_configured'].includes(service.status)
|
||||||
|
).length
|
||||||
|
const activeRecentCount = recent.filter((item) => {
|
||||||
|
const label = String(item.statusLabel ?? '').toLowerCase()
|
||||||
|
return !label.includes('ready') && !label.includes('available') && !label.includes('declined')
|
||||||
|
}).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card">
|
||||||
|
<section className="ops-metric-grid">
|
||||||
|
<div className="ops-metric-card">
|
||||||
|
<span className="section-kicker">Service mesh</span>
|
||||||
|
<strong>
|
||||||
|
{serviceUpCount}/{serviceItems.length || 0}
|
||||||
|
</strong>
|
||||||
|
<p>{servicesLoading ? 'Checking services now.' : 'Configured services online.'}</p>
|
||||||
|
</div>
|
||||||
|
<div className="ops-metric-card">
|
||||||
|
<span className="section-kicker">Attention</span>
|
||||||
|
<strong>{serviceAttentionCount}</strong>
|
||||||
|
<p>Services reporting down, degraded, or not configured.</p>
|
||||||
|
</div>
|
||||||
|
<div className="ops-metric-card">
|
||||||
|
<span className="section-kicker">Loaded requests</span>
|
||||||
|
<strong>{recent.length}</strong>
|
||||||
|
<p>Returned by the live request cache.</p>
|
||||||
|
</div>
|
||||||
|
<div className="ops-metric-card">
|
||||||
|
<span className="section-kicker">Active queue</span>
|
||||||
|
<strong>{activeRecentCount}</strong>
|
||||||
|
<p>Loaded requests still moving through the pipeline.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<div className="layout-grid">
|
<div className="layout-grid">
|
||||||
<section className="recent centerpiece">
|
<section className="recent centerpiece">
|
||||||
<div className="system-status">
|
<div className="system-status">
|
||||||
|
|||||||
@@ -374,7 +374,7 @@ export default function RequestTimelinePage() {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card request-detail-page">
|
||||||
<div className="loading-center" role="status" aria-live="polite">
|
<div className="loading-center" role="status" aria-live="polite">
|
||||||
<div className="spinner" aria-hidden="true" />
|
<div className="spinner" aria-hidden="true" />
|
||||||
<div className="loading-text">Loading request timeline...</div>
|
<div className="loading-text">Loading request timeline...</div>
|
||||||
@@ -384,11 +384,43 @@ export default function RequestTimelinePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (loadError) {
|
if (loadError) {
|
||||||
return <main className="card">{loadError}</main>
|
return (
|
||||||
|
<main className="card request-detail-page">
|
||||||
|
<section className="request-error-state">
|
||||||
|
<span className="section-kicker">Request unavailable</span>
|
||||||
|
<h1>Unable to load this request</h1>
|
||||||
|
<p>{loadError}</p>
|
||||||
|
<div className="request-error-actions">
|
||||||
|
<button type="button" onClick={() => router.refresh()}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost-button" onClick={() => router.push('/')}>
|
||||||
|
Back to health
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return <main className="card">Unable to load this request.</main>
|
return (
|
||||||
|
<main className="card request-detail-page">
|
||||||
|
<section className="request-error-state">
|
||||||
|
<span className="section-kicker">Request unavailable</span>
|
||||||
|
<h1>Unable to load this request</h1>
|
||||||
|
<p>The request API did not return a valid timeline payload.</p>
|
||||||
|
<div className="request-error-actions">
|
||||||
|
<button type="button" onClick={() => router.refresh()}>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost-button" onClick={() => router.push('/')}>
|
||||||
|
Back to health
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const summary =
|
const summary =
|
||||||
@@ -437,7 +469,7 @@ export default function RequestTimelinePage() {
|
|||||||
: friendlyState(snapshot.state)
|
: friendlyState(snapshot.state)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="card">
|
<main className="card request-detail-page">
|
||||||
<div className="request-header">
|
<div className="request-header">
|
||||||
<div className="request-header-main">
|
<div className="request-header-main">
|
||||||
{resolvedPoster && (
|
{resolvedPoster && (
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export default function AdminShell({ title, subtitle, actions, rail, children }:
|
|||||||
<main className="card admin-card">
|
<main className="card admin-card">
|
||||||
<div className="admin-header">
|
<div className="admin-header">
|
||||||
<div>
|
<div>
|
||||||
|
<span className="section-kicker">Beta stream</span>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
{subtitle && <p className="lede">{subtitle}</p>}
|
{subtitle && <p className="lede">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,15 @@
|
|||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
|
|
||||||
const NAV_GROUPS = [
|
const NAV_GROUPS = [
|
||||||
|
{
|
||||||
|
title: 'Operations',
|
||||||
|
items: [
|
||||||
|
{ href: '/admin', label: 'Overview' },
|
||||||
|
{ href: '/', label: 'Health' },
|
||||||
|
{ href: '/portal/requests', label: 'Request portal' },
|
||||||
|
{ href: '/admin/issues', label: 'Issue tracking' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Services',
|
title: 'Services',
|
||||||
items: [
|
items: [
|
||||||
@@ -21,6 +30,7 @@ const NAV_GROUPS = [
|
|||||||
{ href: '/admin/requests', label: 'Request sync' },
|
{ href: '/admin/requests', label: 'Request sync' },
|
||||||
{ href: '/admin/requests-all', label: 'All requests' },
|
{ href: '/admin/requests-all', label: 'All requests' },
|
||||||
{ href: '/admin/cache', label: 'Cache Control' },
|
{ href: '/admin/cache', label: 'Cache Control' },
|
||||||
|
{ href: '/admin/artwork', label: 'Artwork cache' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,44 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
type BrandingLogoProps = {
|
type BrandingLogoProps = {
|
||||||
className?: string
|
className?: string
|
||||||
alt?: string
|
alt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
|
export default function BrandingLogo({ className, alt = 'Magent logo' }: BrandingLogoProps) {
|
||||||
|
const [loaded, setLoaded] = useState(false)
|
||||||
|
const [failed, setFailed] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<span className={`${className ?? ''} branding-logo-shell`} role="img" aria-label={alt}>
|
||||||
|
{!failed ? (
|
||||||
<img
|
<img
|
||||||
className={className}
|
className={loaded ? 'is-loaded' : undefined}
|
||||||
src="/api/branding/logo.png"
|
src="/api/branding/logo.png"
|
||||||
alt={alt}
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
onError={() => setFailed(true)}
|
||||||
/>
|
/>
|
||||||
|
) : null}
|
||||||
|
{!loaded ? (
|
||||||
|
<svg aria-hidden="true" viewBox="0 0 64 64" focusable="false">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="magentLogoGlow" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#7ed7ff" />
|
||||||
|
<stop offset="100%" stopColor="#c6c1ff" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="12" fill="#0b1328" />
|
||||||
|
<rect x="6" y="6" width="52" height="52" rx="9" fill="#111a33" />
|
||||||
|
<path
|
||||||
|
d="M16 48V16h8l8 13 8-13h8v32h-8V30l-8 12-8-12v18h-8z"
|
||||||
|
fill="url(#magentLogoGlow)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { usePathname } from 'next/navigation'
|
||||||
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
|
||||||
|
|
||||||
export default function HeaderActions() {
|
export default function HeaderActions() {
|
||||||
const [signedIn, setSignedIn] = useState(false)
|
const [signedIn, setSignedIn] = useState(false)
|
||||||
|
const [role, setRole] = useState<string | null>(null)
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = getToken()
|
const token = getToken()
|
||||||
@@ -19,9 +22,11 @@ export default function HeaderActions() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
clearToken()
|
clearToken()
|
||||||
setSignedIn(false)
|
setSignedIn(false)
|
||||||
|
setRole(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await response.json()
|
const data = await response.json()
|
||||||
|
setRole(data?.role ?? null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
@@ -33,18 +38,45 @@ export default function HeaderActions() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ href: '/', label: 'Health', icon: '01', match: (path: string) => path === '/' },
|
||||||
|
{
|
||||||
|
href: '/portal/requests',
|
||||||
|
label: 'Requests',
|
||||||
|
icon: '02',
|
||||||
|
match: (path: string) => path === '/portal/requests' || path.startsWith('/requests/'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: '/portal/issues',
|
||||||
|
label: 'Issues',
|
||||||
|
icon: '03',
|
||||||
|
match: (path: string) => path === '/portal/issues' || path === '/admin/issues',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: role === 'admin' ? '/users' : '/profile',
|
||||||
|
label: role === 'admin' ? 'Users' : 'Profile',
|
||||||
|
icon: '04',
|
||||||
|
match: (path: string) => path.startsWith('/users') || path.startsWith('/profile'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: role === 'admin' ? '/admin' : '/profile/invites',
|
||||||
|
label: role === 'admin' ? 'Config' : 'Invites',
|
||||||
|
icon: '05',
|
||||||
|
match: (path: string) => path.startsWith('/admin') || path.startsWith('/profile/invites'),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="header-actions">
|
<nav className="header-actions" aria-label="Primary">
|
||||||
<a className="header-cta header-cta--left" href="/feedback">Send feedback</a>
|
{items.map((item) => {
|
||||||
<div className="header-actions-center">
|
const active = item.match(pathname)
|
||||||
<a href="/how-it-works">How it works</a>
|
return (
|
||||||
</div>
|
<a key={item.href} href={item.href} className={active ? 'is-active' : undefined}>
|
||||||
<div className="header-actions-right">
|
<span aria-hidden="true">{item.icon}</span>
|
||||||
<a href="/">Requests</a>
|
{item.label}
|
||||||
<a href="/profile/invites">Invites</a>
|
</a>
|
||||||
<a href="/portal/requests">Portal</a>
|
)
|
||||||
<a href="/portal/issues">Issues</a>
|
})}
|
||||||
</div>
|
</nav>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user