348 lines
13 KiB
TypeScript
348 lines
13 KiB
TypeScript
'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 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() {
|
|
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)
|
|
|
|
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 : [])
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}, [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>
|
|
</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>
|
|
</section>
|
|
</AdminShell>
|
|
)
|
|
}
|